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)

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>
This commit is contained in:
hataa 2026-09-30 19:45:07 +08:00 committed by GitHub
parent 8432e4b71a
commit 1cd3eb742b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 4983 additions and 86 deletions

View File

@ -492,6 +492,7 @@ def build_middlewares(
mcp_routing_middleware: AgentMiddleware | None = None,
user_id: str | None = None,
authorization_provider=None,
skill_authorization=None,
extensions=None,
subagent_execution_capacity: int | None = None,
):
@ -521,6 +522,11 @@ def build_middlewares(
to ``SkillActivationMiddleware`` so it can resolve per-user custom skills.
authorization_provider: Provider already resolved for assembly-time
filtering. Reused by the execution-time authorization middleware.
skill_authorization: Provider + principal already resolved for the
skill visibility filter. Passed through to
``SkillActivationMiddleware`` so explicit slash activation also
enforces the action-scoped ``skill:activate`` decision (custom
providers may distinguish visibility from activation).
subagent_execution_capacity: Startup-frozen process capacity used to
keep advertised and enforced task concurrency aligned after reloads.
extensions: Loaded extensions whose middleware contributions are merged
@ -543,6 +549,15 @@ def build_middlewares(
runtime_middleware_kwargs["owns_agent_skill_projection"] = False
if authorization_provider is not None:
runtime_middleware_kwargs["authorization_provider"] = authorization_provider
if skill_authorization is not None:
# Shared with the runtime chain (ToolErrorHandlingMiddleware gates the
# skill-file-load stamp on the same skill:activate decision the
# activation middleware enforces for slash commands).
runtime_middleware_kwargs["skill_authorization"] = skill_authorization
if user_id is not None:
# ToolErrorHandlingMiddleware canonicalizes skill-read paths through
# the same user-scoped registry the activation/policy middlewares use.
runtime_middleware_kwargs["user_id"] = user_id
if authorization_provider is not None and deferred_setup is not None:
runtime_middleware_kwargs["deferred_setup"] = deferred_setup
middlewares = build_lead_runtime_middlewares(**runtime_middleware_kwargs)
@ -571,6 +586,7 @@ def build_middlewares(
app_config=resolved_app_config,
user_id=user_id,
slash_source_owner_token=slash_source_owner_token,
skill_authorization=skill_authorization,
)
)
@ -592,6 +608,10 @@ def build_middlewares(
app_config=resolved_app_config,
user_id=user_id,
slash_source_owner_token=slash_source_owner_token,
# Persisted skill_context entries are re-authorized against the
# skill:activate decision before their allowed-tools apply (the
# policy may have changed since the entry was stamped).
skill_authorization=skill_authorization,
)
)
@ -607,6 +627,11 @@ def build_middlewares(
inject_tool_artifacts=resolved_app_config.tool_artifacts.enabled and resolved_app_config.tool_artifacts.inject_model_context,
task_continuity_enabled=getattr(getattr(resolved_app_config, "task_continuity", None), "enabled", False) is True,
pii_redaction_config=getattr(resolved_app_config, "pii_redaction", None),
# The rendered "Active skills" reminder hides entries the provider
# denies; decisions are published per step by the activation
# middleware under the shared chain token.
skill_authorization=skill_authorization,
entry_decisions_owner_token=slash_source_owner_token,
)
)
@ -972,6 +997,46 @@ def _assemble_lead_agent(config: RunnableConfig, *, app_config: AppConfig) -> Le
if isinstance(config.get("context"), dict):
config["context"]["subagent_enabled"] = subagent_enabled
available_skills = _available_skill_names(agent_config, is_bootstrap)
# Phase 3: enforce skill authorization (Layer 1). Filter the skill
# allowlist by the provider's "skill" policy so denied skills never
# appear in <skill_index>, can never be describe_skill'd, and cannot be
# slash-activated (SkillActivationMiddleware checks this set). When
# authorization is disabled, this is a no-op. ``available_skills=None``
# means "no agent-level allowlist" (all enabled skills); authorization
# still constrains via filter_resources in that case. The resolved
# provider/principal are threaded into ``SkillActivationMiddleware`` so
# explicit activation additionally enforces the action-scoped
# ``skill:activate`` decision (visibility != activation for custom
# providers).
from deerflow.authz.skill_filter import filter_available_skills_by_authorization, resolve_skill_authorization
skill_authorization = resolve_skill_authorization(cfg, resolved_app_config)
# Resolve the filter's candidate names through the cached catalog loader
# instead of letting the filter rescan storage: the enabled-skills load
# below (``_load_enabled_available_skills``) uses the same
# config-identity cache, so a build pays one skill-tree walk when the
# cache is cold and none when warm — the filter's own uncached fallback
# double-pays it on every build (notably costly on NFS/CSI roots).
candidate_skill_names = None
if available_skills is None and skill_authorization is not None:
try:
from deerflow.agents.lead_agent.prompt import get_enabled_skills_for_config
candidate_skill_names = [s.name for s in get_enabled_skills_for_config(resolved_app_config, user_id=resolved_user_id)]
except Exception:
logger.warning("Failed to pre-load enabled skills for authorization candidates", exc_info=True)
# Leave None: the filter resolves candidates itself and applies
# its fail-closed semantics when that resolution also fails.
available_skills = filter_available_skills_by_authorization(
available_skills,
context=cfg,
app_config=resolved_app_config,
user_id=resolved_user_id,
candidate_skill_names=candidate_skill_names,
authorization=skill_authorization,
)
# Custom agent model from agent config (if any), or None to let _resolve_model_name pick the default
agent_model_name = agent_config.model if agent_config and agent_config.model else None
@ -1080,6 +1145,7 @@ def _assemble_lead_agent(config: RunnableConfig, *, app_config: AppConfig) -> Le
bootstrap_skills,
enabled=skill_search_enabled,
container_base_path=container_base_path,
skill_authorization=skill_authorization,
)
chat_model = create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=resolved_app_config, attach_tracing=False)
raw_tools = get_available_tools(model_name=model_name, subagent_enabled=subagent_enabled, app_config=resolved_app_config, chat_model=chat_model) + [setup_agent]
@ -1111,7 +1177,12 @@ def _assemble_lead_agent(config: RunnableConfig, *, app_config: AppConfig) -> Le
config,
model_name=model_name,
agent_name=agent_name,
available_skills=set(_BOOTSTRAP_SKILL_NAMES),
# The authorization-filtered bootstrap set, not the raw
# _BOOTSTRAP_SKILL_NAMES: when policy denies "bootstrap" this is an
# empty set, which the activation middleware's membership gate
# treats as "no skills activatable" (a raw {"bootstrap"} would
# re-admit the denied skill to slash activation).
available_skills=available_skills,
memory_enabled=memory_enabled,
owns_agent_skill_projection=False,
app_config=resolved_app_config,
@ -1119,17 +1190,28 @@ def _assemble_lead_agent(config: RunnableConfig, *, app_config: AppConfig) -> Le
mcp_routing_middleware=mcp_routing_middleware,
user_id=resolved_user_id,
authorization_provider=_authz_provider,
skill_authorization=skill_authorization,
subagent_execution_capacity=subagent_execution_capacity,
)
system_prompt = apply_prompt_template(
subagent_enabled=subagent_enabled,
max_concurrent_subagents=max_concurrent_subagents,
max_total_subagents=max_total_subagents,
available_skills=set(_BOOTSTRAP_SKILL_NAMES),
# Same filtered set as build_middlewares above; the legacy prompt
# path also consults it, and an unfiltered {"bootstrap"} would let
# the full-metadata rendering advertise a policy-denied skill.
available_skills=available_skills,
app_config=resolved_app_config,
deferred_names=setup.deferred_names,
user_id=resolved_user_id,
skill_names=skill_setup.skill_names or None,
# Preserve the (possibly empty) index when deferred discovery is
# on: converting an empty set back to None would fall through to
# the legacy full-metadata prompt, which reloads every enabled
# skill from storage and re-advertises the denied one. When
# deferred discovery is off, None keeps the legacy rendering and
# the filtered available_skills above already blanks it for a
# denial.
skill_names=skill_setup.skill_names if skill_search_enabled else None,
allowed_subagents=allowed_subagents,
subagent_execution_capacity=subagent_execution_capacity,
interaction_policy=interaction_policy,
@ -1187,6 +1269,7 @@ def _assemble_lead_agent(config: RunnableConfig, *, app_config: AppConfig) -> Le
enabled_skills,
enabled=skill_search_enabled,
container_base_path=container_base_path,
skill_authorization=skill_authorization,
)
#
# Withhold ``update_agent`` from runs triggered by webhook channels
@ -1251,6 +1334,7 @@ def _assemble_lead_agent(config: RunnableConfig, *, app_config: AppConfig) -> Le
mcp_routing_middleware=mcp_routing_middleware,
user_id=resolved_user_id,
authorization_provider=_authz_provider,
skill_authorization=skill_authorization,
subagent_execution_capacity=subagent_execution_capacity,
)
system_prompt = apply_prompt_template(

View File

@ -308,6 +308,8 @@ class DurableContextMiddleware(AgentMiddleware[AgentState]):
inject_tool_artifacts: bool = True,
task_continuity_enabled: bool = False,
pii_redaction_config: PiiRedactionConfig | None = None,
skill_authorization=None,
entry_decisions_owner_token: str | None = None,
) -> None:
super().__init__()
self._task_continuity_enabled = task_continuity_enabled
@ -315,6 +317,8 @@ class DurableContextMiddleware(AgentMiddleware[AgentState]):
self._skills_root = _normalize_skills_root(skills_container_path)
self._skill_read_tool_names = frozenset(DEFAULT_SKILL_FILE_READ_TOOL_NAMES if skill_file_read_tool_names is None else skill_file_read_tool_names)
self._inject_tool_artifacts = inject_tool_artifacts
self._skill_authorization = skill_authorization
self._entry_decisions_owner_token = entry_decisions_owner_token
def release_policy_parameters(self) -> dict[str, object]:
"""Describe the normalized inputs that govern capture and injection."""
@ -324,8 +328,32 @@ class DurableContextMiddleware(AgentMiddleware[AgentState]):
"task_continuity_enabled": self._task_continuity_enabled,
"inject_tool_artifacts": self._inject_tool_artifacts,
"pii_redaction_enabled": bool(self._pii_redaction_config and self._pii_redaction_config.enabled),
"skill_entry_render_filter": bool(self._skill_authorization),
}
def _renderable_skills(self, request: ModelRequest, entries: list) -> list:
"""Filter persisted ``skill_context`` entries the provider now denies.
The rendered "Active skills" reminder must not advertise a skill whose
``skill:activate`` decision is denied — the same denial
``describe_skill`` applies to the catalog. Decisions come from the
skill-activation middleware's per-step publication (path-keyed, owner
token authenticated); this middleware performs no provider or storage
work of its own. Nothing published (authorization disabled, no
entries, or a foreign carrier) renders unchanged — absent is
permissive, matching the other run-context carriers. A published
``False`` hides the entry from the reminder only; state is untouched.
"""
if not entries or self._skill_authorization is None or not self._entry_decisions_owner_token:
return entries
from deerflow.runtime.secret_context import read_skill_entry_decisions
context = getattr(getattr(request, "runtime", None), "context", None)
decisions = read_skill_entry_decisions(context, owner_token=self._entry_decisions_owner_token)
if decisions is None:
return entries
return [entry for entry in entries if isinstance(entry, dict) and decisions.get(posixpath.normpath(entry.get("path") or ""), False)]
@override
def before_model(self, state: AgentState, runtime: Runtime) -> dict | None:
return self._capture(state, runtime)
@ -385,7 +413,7 @@ class DurableContextMiddleware(AgentMiddleware[AgentState]):
data_block = _render_durable_context_data(
redact_text(state.get("summary_text"), self._pii_redaction_config),
state.get("delegations") or [],
state.get("skill_context") or [],
self._renderable_skills(request, state.get("skill_context") or []),
(state.get("task_notes") or {}) if self._task_continuity_enabled else None,
state.get("task_history") if self._task_continuity_enabled else None,
artifacts=artifacts,

View File

@ -38,6 +38,7 @@ from deerflow.skills.types import SKILL_MD_FILE, SecretRequirement, Skill, Skill
from deerflow.utils.messages import get_original_user_content_text, is_real_user_message
if TYPE_CHECKING:
from deerflow.authz.skill_filter import ResolvedSkillAuthorization
from deerflow.config.app_config import AppConfig
logger = logging.getLogger(__name__)
@ -45,6 +46,15 @@ logger = logging.getLogger(__name__)
_SLASH_SKILL_ACTIVATION_KEY = "slash_skill_activation"
_SLASH_SKILL_ACTIVATION_TARGET_ID_KEY = "slash_skill_activation_target_id"
# Async-prepass registry load already attempted and failed. Handed down so
# the secret-binding resolution does not silently recover with a fresh load:
# entry names resolved from a recovered registry would miss the (empty)
# decision map and fall back to the synchronous provider API from the worker
# thread — for a loop-affine provider a denial becomes a fail-open allow.
# Entries bind nothing for that call instead (fail closed by construction).
# Same marker pattern as SkillToolPolicyMiddleware._REGISTRY_LOAD_FAILED.
_REGISTRY_LOAD_FAILED = object()
# _SECRETS_BINDING_AUDIT_KEY: last audited binding (skill and secret names only,
# never values) so unchanged bindings are not re-recorded each call.
# The shared slash-source context contract holds the latest slash activation,
@ -100,6 +110,7 @@ class SkillActivationMiddleware(AgentMiddleware):
app_config: AppConfig | None = None,
user_id: str | None = None,
slash_source_owner_token: str,
skill_authorization: ResolvedSkillAuthorization | None = None,
) -> None:
super().__init__()
if not isinstance(slash_source_owner_token, str) or not slash_source_owner_token:
@ -108,6 +119,117 @@ class SkillActivationMiddleware(AgentMiddleware):
self._app_config = app_config
self._user_id = user_id
self._slash_source_owner_token = slash_source_owner_token
self._skill_authorization = skill_authorization
def _activation_allowed(self, skill_name: str, *, activation_decisions: dict[str, bool] | None = None) -> bool:
"""Action-scoped ``skill:activate`` check for explicit slash activation.
``_available_skills`` is the Layer 1 *visibility* set (filter_resources,
action-agnostic); an action-aware custom provider may expose a skill
there while denying its activation. Mirrors ``_authorize_model_name``'s
second ``authorize("model", "use")`` check. Provider errors follow the
configured fail-closed / fail-open policy. ``None`` (authorization
disabled) allows — membership in the visibility set already decided.
Delegates to the shared ``skill_activation_allowed`` so the slash path,
``describe_skill``, and the skill-file-load path cannot drift.
*activation_decisions* carries decisions precomputed on the event loop
via ``aauthorize()`` by ``awrap_model_call``: the threaded handler then
consults them instead of calling the synchronous ``authorize()`` (wrong
API for loop-affine providers). Names absent from the map fall back to
the synchronous check, which is the correct API for the sync
``wrap_model_call`` path.
"""
if activation_decisions is not None and skill_name in activation_decisions:
return activation_decisions[skill_name]
from deerflow.authz.skill_filter import skill_activation_allowed
return skill_activation_allowed(self._skill_authorization, skill_name)
async def _collect_activation_decisions(self, names) -> dict[str, bool] | None:
"""Precompute ``skill:activate`` decisions on the event loop.
Async middleware paths offload the blocking handler (skill-tree reads)
to a worker thread, but the provider decision belongs on the loop with
``aauthorize()``. Collects every candidate name the handler may need —
the slash target and the persisted ``skill_context`` entries — so one
batch of awaits covers both the activation and secret-binding gates.
``None`` means authorization is disabled (nothing to precompute).
"""
if self._skill_authorization is None:
return None
candidates = {name for name in names if isinstance(name, str) and name}
if not candidates:
return None
from deerflow.authz.skill_filter import skill_activation_allowed_async
decisions: dict[str, bool] = {}
for name in sorted(candidates):
decisions[name] = await skill_activation_allowed_async(self._skill_authorization, name)
return decisions
def _candidate_activation_targets(self, request: ModelRequest) -> tuple[list[str], list[str]]:
"""Split the activation candidates into (names, entry_paths).
Loop-safe (no storage I/O): the slash reference is parsed straight
from the latest real user message — its name is already canonical,
because ``_resolve_activation`` matches it against the registry's
``Skill.name`` set. The persisted ``skill_context`` entries contribute
their *paths*: the stamped ``entry["name"]`` is path-derived (the
directory name) and can differ from the declared ``Skill.name``, so
the consumer (``_in_context_secret_sources``) resolves the path
through the live registry — the decision map must be keyed by the
same canonical names, resolved off-loop by the caller.
"""
names: list[str] = []
entry_paths: list[str] = []
messages = list(request.messages)
for index in range(len(messages) - 1, -1, -1):
if _is_user_activation_target(messages[index]):
content = get_original_user_content_text(messages[index].content, messages[index].additional_kwargs)
reference = parse_slash_skill_reference(content)
if reference is not None:
names.append(reference.name)
break
state = getattr(request, "state", None) or {}
try:
entries = state.get("skill_context") or []
except AttributeError:
entries = []
for entry in entries:
if not isinstance(entry, dict):
continue
path = entry.get("path")
if isinstance(path, str) and path:
entry_paths.append(path)
return names, entry_paths
def _canonical_names_for_paths(self, paths: list[str]) -> tuple[list[str], dict[str, Skill] | object]:
"""Canonical ``Skill.name`` for the entry paths plus the registry they
came from (thread-only; disk I/O).
Returns ``(names, registry)`` where *registry* is the snapshot the
names were resolved from — the async caller hands it to the secret
binding resolution so entries are resolved against the SAME snapshot
the decision map was keyed by (a map miss is then impossible by
construction, closing the sync-API fallback divergence). On storage
failure returns ``([], _REGISTRY_LOAD_FAILED)``: entries must bind
nothing rather than be re-resolved from a silently recovered fresh
load. Returns immediately for an empty list: this helper runs on
every authorization-enabled async model step, and an ordinary request
(no slash reference, no persisted entries, no supplied secrets) must
not pay a full skill-tree scan per LLM call.
"""
if not paths:
return [], None
try:
from deerflow.skills.container_registry import build_container_path_registry, canonical_skill_name
registry = build_container_path_registry(self._storage())
except Exception:
logger.exception("Failed to load skills while collecting activation candidates")
return [], _REGISTRY_LOAD_FAILED
return [name for name in (canonical_skill_name(registry, path) for path in paths) if name is not None], registry
def release_policy_parameters(self) -> dict[str, object]:
return {
@ -145,7 +267,7 @@ class SkillActivationMiddleware(AgentMiddleware):
raise FileNotFoundError(resolved_file)
return resolved_file.read_text(encoding="utf-8")
def _resolve_activation(self, text: str) -> _ActivationResolution | None:
def _resolve_activation(self, text: str, *, activation_decisions: dict[str, bool] | None = None) -> _ActivationResolution | None:
reference = parse_slash_skill_reference(text)
if reference is None:
return None
@ -159,6 +281,11 @@ class SkillActivationMiddleware(AgentMiddleware):
return _ActivationResolution(failure_message=f"Skill `/{reference.name}` is installed but disabled. Enable it before using slash activation.")
if self._available_skills is not None and reference.name not in self._available_skills:
return _ActivationResolution(failure_message=f"Skill `/{reference.name}` is not available for this agent.")
if not self._activation_allowed(reference.name, activation_decisions=activation_decisions):
# Visible in the Layer 1 set but denied by the action-scoped
# skill:activate policy — same user-facing message as the
# membership denial so the reason is not leaked.
return _ActivationResolution(failure_message=f"Skill `/{reference.name}` is not available for this agent.")
resolved = resolve_slash_skill(
text,
@ -268,7 +395,7 @@ Follow this skill before choosing a general workflow. Load supporting resources
"""
return isinstance(run_context, dict) and run_context.get(_SLASH_SKILL_ACTIVATION_RUN_KEY) == run_key
def _find_activation_target(self, messages: list, *, run_context: dict | None = None) -> tuple[int, HumanMessage, _ActivationResolution, str] | None:
def _find_activation_target(self, messages: list, *, run_context: dict | None = None, activation_decisions: dict[str, bool] | None = None) -> tuple[int, HumanMessage, _ActivationResolution, str] | None:
if not messages:
return None
@ -293,7 +420,7 @@ Follow this skill before choosing a general workflow. Load supporting resources
return None
content = get_original_user_content_text(target.content, target.additional_kwargs)
resolution = self._resolve_activation(content)
resolution = self._resolve_activation(content, activation_decisions=activation_decisions)
if resolution is None:
return None
return target_index, target, resolution, run_key
@ -321,9 +448,9 @@ Follow this skill before choosing a general workflow. Load supporting resources
except Exception:
logger.warning("Failed to record slash skill activation audit event", exc_info=True)
def _prepare_model_request(self, request: ModelRequest, *, hook: str) -> tuple[ModelRequest | AIMessage | None, _Activation | None]:
def _prepare_model_request(self, request: ModelRequest, *, hook: str, activation_decisions: dict[str, bool] | None = None) -> tuple[ModelRequest | AIMessage | None, _Activation | None]:
run_context = self._run_context(request)
target_and_resolution = self._find_activation_target(list(request.messages), run_context=run_context)
target_and_resolution = self._find_activation_target(list(request.messages), run_context=run_context, activation_decisions=activation_decisions)
if target_and_resolution is None:
return None, None
@ -359,12 +486,19 @@ Follow this skill before choosing a general workflow. Load supporting resources
messages.insert(target_index, activation_msg)
return request.override(messages=messages), activation
def _handle_model_request(self, request: ModelRequest, *, hook: str) -> tuple[ModelRequest | AIMessage, _Activation | None]:
prepared, activation = self._prepare_model_request(request, hook=hook)
def _handle_model_request(
self,
request: ModelRequest,
*,
hook: str,
activation_decisions: dict[str, bool] | None = None,
entry_registry: dict[str, Skill] | object | None = None,
) -> tuple[ModelRequest | AIMessage, _Activation | None]:
prepared, activation = self._prepare_model_request(request, hook=hook, activation_decisions=activation_decisions)
if isinstance(prepared, AIMessage):
return prepared, None
effective = prepared if prepared is not None else request
self._resolve_secret_bindings(effective, activation, hook=hook)
self._resolve_secret_bindings(effective, activation, hook=hook, activation_decisions=activation_decisions, entry_registry=entry_registry)
if activation is not None:
record_skill_usage(getattr(request, "runtime", None), self._usage_snapshot(activation))
return effective, activation
@ -395,7 +529,15 @@ Follow this skill before choosing a general workflow. Load supporting resources
break
return response
def _resolve_secret_bindings(self, request: ModelRequest, activation: _Activation | None, *, hook: str) -> None:
def _resolve_secret_bindings(
self,
request: ModelRequest,
activation: _Activation | None,
*,
hook: str,
activation_decisions: dict[str, bool] | None = None,
entry_registry: dict[str, Skill] | object | None = None,
) -> None:
"""Recompute the per-run secret injection set (binding point A+, #3861/#3914).
Sources, unioned on every model call:
@ -440,15 +582,65 @@ Follow this skill before choosing a general workflow. Load supporting resources
request_secrets = extract_request_secrets(context)
sources: list[tuple[str, tuple[SecretRequirement, ...]]] = []
if request_secrets:
registry = self._load_skill_registry_by_path()
if registry is not None:
# Two secret sources, two deliberately DIFFERENT registry
# reference points:
#
# Slash source — always a fresh, post-activation registry. Its
# binding must match the skill content the activation just read;
# the prepass snapshot is taken BEFORE the aauthorize awaits, so
# resolving the slash source from it would activate NEW_KEY
# content while binding OLD_KEY when a skill's declared secrets
# change in that window (review: keep the fresh slash lookup
# separate from the entry snapshot). The slash source never
# consults the decision map, so snapshot consistency buys nothing
# here.
slash_registry = self._load_skill_registry_by_path()
# Entry sources — the snapshot the decision map was keyed by
# (a map miss is impossible by construction), or an empty
# registry after a failed prepass (bind nothing rather than
# silently recover with a fresh load whose names would miss the
# map and fall back to the sync provider API). ``None`` (sync
# chain / prepass not run) falls back to the same fresh registry
# the slash source uses — on the sync chain the synchronous
# authorize() is the correct API, so no divergence exists there.
if entry_registry is _REGISTRY_LOAD_FAILED:
entry_registry_effective: dict[str, Skill] = {}
elif entry_registry is not None:
entry_registry_effective = entry_registry
else:
entry_registry_effective = slash_registry if slash_registry is not None else {}
# The two sources resolve independently — a transient failure of
# the fresh slash lookup must not zero the entry sources (they
# have their own snapshot and never consult that registry).
slash_path = read_slash_skill_source_path(context, owner_token=self._slash_source_owner_token)
if slash_registry is not None:
# Slash source: exempt from the ``secrets-autonomous`` opt-out
# (explicit ceremony), but still enabled + allowlist checked.
slash_path = read_slash_skill_source_path(context, owner_token=self._slash_source_owner_token)
slash_skill = self._resolve_registry_skill(registry, slash_path, require_autonomous=False)
slash_skill = self._resolve_registry_skill(slash_registry, slash_path, require_autonomous=False)
if slash_skill is not None:
sources.append((slash_skill.name, tuple(slash_skill.required_secrets)))
sources.extend(self._in_context_secret_sources(request, registry))
# Entry sources resolve independently. The same-skill exclusion is
# anchored on the AUTHENTICATED slash activation identity — not on
# binding success, and not on the declared name alone: a
# slash-activated skill whose current version declares no secrets
# binds nothing itself, yet must still suppress its stale entry
# view, and a mid-run rename (same path, new declared name) would
# otherwise slip a name-anchored exclusion. The PATH is the stable
# identity shared by the activation and every earlier read, so the
# identity path always excludes; the resolved names (fresh registry
# first, then the entry snapshot) additionally cover the cross-path
# same-name shadowing case.
slash_bound = {name for name, _ in sources}
identity_paths: frozenset[str] = frozenset()
if isinstance(slash_path, str) and slash_path:
normalized_identity = posixpath.normpath(slash_path)
identity_paths = frozenset({normalized_identity})
for registry in (slash_registry, entry_registry_effective):
identity = registry.get(normalized_identity) if isinstance(registry, dict) else None
if identity is not None:
slash_bound.add(identity.name)
break
sources.extend(self._in_context_secret_sources(request, entry_registry_effective, activation_decisions=activation_decisions, exclude_names=frozenset(slash_bound), exclude_paths=identity_paths))
injected: dict[str, str] = {}
bound_skills: set[str] = set()
@ -505,13 +697,12 @@ Follow this skill before choosing a general workflow. Load supporting resources
call rather than trusting stale caller-supplied data.
"""
try:
storage = self._storage()
skills = storage.load_skills(enabled_only=False)
container_root = storage.get_container_root()
from deerflow.skills.container_registry import build_container_path_registry
return build_container_path_registry(self._storage())
except Exception:
logger.exception("Failed to load skills while resolving secret bindings")
return None
return {posixpath.normpath(skill.get_container_file_path(container_root)): skill for skill in skills}
def _resolve_registry_skill(self, registry: dict[str, Skill], path: object, *, require_autonomous: bool) -> Skill | None:
"""Resolve a container path to a live registry skill eligible for secret
@ -541,13 +732,35 @@ Follow this skill before choosing a general workflow. Load supporting resources
return None
return skill
def _in_context_secret_sources(self, request: ModelRequest, registry: dict[str, Skill]) -> list[tuple[str, tuple[SecretRequirement, ...]]]:
def _in_context_secret_sources(
self,
request: ModelRequest,
registry: dict[str, Skill],
*,
activation_decisions: dict[str, bool] | None = None,
exclude_names: frozenset[str] = frozenset(),
exclude_paths: frozenset[str] = frozenset(),
) -> list[tuple[str, tuple[SecretRequirement, ...]]]:
"""Map ``ThreadState.skill_context`` entries to declared-secret sources.
Entries are references to skills the model actually loaded in this
thread. Each is re-validated against the live registry so a skill that
was disabled, uninstalled, opted out, or removed from the agent's
allowlist after being read stops binding immediately.
allowlist after being read stops binding immediately. The
``skill:activate`` decision is re-checked here as well: ``skill_context``
persists across runs, and an entry stamped under an earlier policy must
not keep binding secrets after the provider starts denying activation.
*exclude_names* carries the slash-activated skill names: explicit slash
activation dominates for the rest of the run (the same rule the
tool-policy middleware applies), and the slash source is resolved from
a fresher registry than the entry snapshot — letting the same skill
contribute both sources would bind two eras of its declarations when
they change across the prepass→activation window. *exclude_paths*
carries the authenticated slash identity's container path — the stable
identity across mid-run renames, where the same path's old and new
versions carry different declared names and a name-anchored exclusion
alone would slip.
"""
state = getattr(request, "state", None) or {}
try:
@ -560,9 +773,21 @@ Follow this skill before choosing a general workflow. Load supporting resources
for entry in entries:
if not isinstance(entry, dict):
continue
skill = self._resolve_registry_skill(registry, entry.get("path"), require_autonomous=True)
entry_path = entry.get("path")
normalized_entry_path = posixpath.normpath(entry_path) if isinstance(entry_path, str) and entry_path else None
if normalized_entry_path is not None and normalized_entry_path in exclude_paths:
# The slash-activated identity owns this path; its entry view
# must not widen or stale it under any declared name.
continue
skill = self._resolve_registry_skill(registry, entry_path, require_autonomous=True)
if skill is None or skill.name in seen:
continue
if skill.name in exclude_names:
# The slash source already binds this skill's activation-era
# declaration; its entry view must not widen or stale it.
continue
if not self._activation_allowed(skill.name, activation_decisions=activation_decisions):
continue
seen.add(skill.name)
sources.append((skill.name, tuple(skill.required_secrets)))
return sources
@ -605,18 +830,92 @@ Follow this skill before choosing a general workflow. Load supporting resources
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelResponse | AIMessage:
# Sync-chain twin of the async publication: when authorization is
# enabled and persisted entries exist, resolve one registry snapshot
# plus the synchronous authorize() decisions and publish the path-keyed
# map for the durable renderer. Passive steps (no entries) publish
# nothing and pay no I/O, mirroring the async empty-paths guard.
if self._skill_authorization is not None:
_, entry_paths = self._candidate_activation_targets(request)
if entry_paths:
names, registry = self._canonical_names_for_paths(entry_paths)
decisions = self._collect_sync_activation_decisions(names)
self._publish_entry_decisions(request, entry_paths, registry, decisions)
prepared, activation = self._handle_model_request(request, hook="wrap_model_call")
if isinstance(prepared, AIMessage):
return prepared
return self._stamp_usage(handler(prepared), activation)
def _publish_entry_decisions(
self,
request: ModelRequest,
entry_paths: list[str],
entry_registry: dict[str, Skill] | object | None,
activation_decisions: dict[str, bool] | None,
) -> None:
"""Publish per-step ``skill:activate`` decisions keyed by entry path.
The durable-context renderer consumes this map so the model-visible
"Active skills" reminder stops advertising skills the provider now
denies. Keyed by normalized container path (the renderer's entry
identity); a prepass registry failure, an unresolvable path, or a
missing decision all publish ``False`` — hide rather than advertise.
"""
run_context = self._run_context(request)
if run_context is None:
return
from deerflow.runtime.secret_context import write_skill_entry_decisions
decisions: dict[str, bool] = {}
registry = entry_registry if isinstance(entry_registry, dict) else {}
collected = activation_decisions or {}
for path in entry_paths:
normalized = posixpath.normpath(path)
skill = registry.get(normalized)
decisions[normalized] = collected.get(skill.name, False) if skill is not None else False
write_skill_entry_decisions(run_context, decisions, owner_token=self._slash_source_owner_token)
def _collect_sync_activation_decisions(self, names: list[str]) -> dict[str, bool] | None:
"""Synchronous authorize() decisions for *names* (sync chain only)."""
if self._skill_authorization is None or not names:
return None
from deerflow.authz.skill_filter import skill_activation_allowed
return {name: skill_activation_allowed(self._skill_authorization, name) for name in names}
@override
async def awrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
) -> ModelResponse | AIMessage:
prepared, activation = await asyncio.to_thread(self._handle_model_request, request, hook="awrap_model_call")
# Resolve the skill:activate decisions on the event loop with the
# provider's async API, then hand them to the worker thread: the
# blocking handler (skill-tree reads) keeps its to_thread offload while
# loop-affine providers still receive aauthorize() — a synchronous
# authorize() from the thread is the wrong API for them. Entry paths
# are canonicalized through the registry in a worker thread first so
# the decision map is keyed by declared Skill.name (the same names the
# threaded handler's registry lookups produce), not directory names.
# The snapshot those names came from travels with the decisions: the
# secret-binding resolution reuses it for the entry sources, so a map
# miss is impossible by construction (and a failed load means entries
# bind nothing — no silent fresh-load recovery).
entry_registry: dict[str, Skill] | object | None = None
if self._skill_authorization is not None:
slash_names, entry_paths = self._candidate_activation_targets(request)
canonical_names, entry_registry = await asyncio.to_thread(self._canonical_names_for_paths, entry_paths)
activation_decisions = await self._collect_activation_decisions([*slash_names, *canonical_names])
self._publish_entry_decisions(request, entry_paths, entry_registry, activation_decisions)
else:
activation_decisions = None
prepared, activation = await asyncio.to_thread(
self._handle_model_request,
request,
hook="awrap_model_call",
activation_decisions=activation_decisions,
entry_registry=entry_registry,
)
if isinstance(prepared, AIMessage):
return prepared
return self._stamp_usage(await handler(prepared), activation)

View File

@ -20,6 +20,13 @@ _SKILL_FILE_NAME = "SKILL.md"
# here the match fails on the leading marker and the captured description comes back empty.
_FRONT_MATTER_RE = re.compile(r"^\ufeff?---\s*\n(.*?)\n---\s*\n", re.DOTALL)
SKILL_CONTEXT_ENTRY_KEY = "skill_context_entry"
# Stamped instead of SKILL_CONTEXT_ENTRY_KEY when the read SKILL.md belongs to
# a skill whose action-scoped ``skill:activate`` decision is denied: the file
# content was served (Layer 1 visibility allows it) but the skill must not
# activate — no durable skill_context entry, tool policy, or secret binding.
# extract_skills uses it to skip the entry without the "missing skill read
# metadata" warning that an unstamped read would otherwise produce.
SKILL_CONTEXT_DENIED_KEY = "skill_context_denied"
logger = logging.getLogger(__name__)
@ -160,6 +167,11 @@ def extract_skills(
expected_path = skill_paths_by_id.get(tool_call_id)
if expected_path is None:
continue
if (message.additional_kwargs or {}).get(SKILL_CONTEXT_DENIED_KEY):
# The skill:activate decision denied this read's skill — the
# middleware stamped the denial instead of entry metadata on
# purpose, so this is not the "missing metadata" bug shape.
continue
metadata = read_skill_entry_metadata(message.additional_kwargs)
if metadata is None:
content = message.content if isinstance(message.content, str) else ""

View File

@ -34,6 +34,18 @@ _POLICY_SOURCE_SLASH = "slash"
_POLICY_SOURCE_SKILL_CONTEXT = "skill_context"
_POLICY_SOURCES = frozenset({_POLICY_SOURCE_PASSIVE, _POLICY_SOURCE_SLASH, _POLICY_SOURCE_SKILL_CONTEXT})
_MISSING_POLICY_DECISION = object()
# Async-prepass registry load already attempted and failed: the async hook must
# not let the worker-side filter silently retry storage — a successful retry
# would resolve skills whose names are absent from the (empty) decision map and
# fall back to the synchronous provider API from the thread, which for a
# loop-affine provider turns a denial into a fail-open allow. Tri-state marker
# in the style of _MISSING_POLICY_DECISION.
# Registry argument handed down the policy-resolution call chain: a loaded
# snapshot (dict), the load-failure marker, or None ("load it here" — the
# sync-path behavior).
type _RegistryArg = dict[str, Skill] | object | None
_REGISTRY_LOAD_FAILED = object()
_TOOL_SEARCH_NAME = "tool_search"
type _PolicySignature = tuple[str, tuple[str, ...]]
@ -56,6 +68,7 @@ class SkillToolPolicyMiddleware(AgentMiddleware[AgentState]):
app_config: AppConfig | None = None,
user_id: str | None = None,
slash_source_owner_token: str,
skill_authorization=None,
) -> None:
super().__init__()
if not isinstance(slash_source_owner_token, str) or not slash_source_owner_token:
@ -64,8 +77,79 @@ class SkillToolPolicyMiddleware(AgentMiddleware[AgentState]):
self._app_config = app_config
self._user_id = user_id
self._slash_source_owner_token = slash_source_owner_token
self._skill_authorization = skill_authorization
self._decision_owner_token = secrets.token_urlsafe(24)
def _activation_allowed(self, skill_name: str, *, activation_decisions: dict[str, bool] | None = None) -> bool:
"""Action-scoped ``skill:activate`` decision for one skill name.
Persisted ``skill_context`` entries were authorized when they were
stamped, but the policy may have changed since — every entry is
re-authorized before its allowed-tools declaration is applied.
*activation_decisions* carries decisions precomputed on the event loop
via ``aauthorize()`` (async hooks); names absent from the map fall back
to the synchronous check, which is the correct API for the sync hooks.
"""
if self._skill_authorization is None:
return True
if activation_decisions is not None and skill_name in activation_decisions:
return activation_decisions[skill_name]
from deerflow.authz.skill_filter import skill_activation_allowed
return skill_activation_allowed(self._skill_authorization, skill_name)
async def _collect_activation_decisions(self, names) -> dict[str, bool] | None:
"""Precompute ``skill:activate`` decisions on the event loop (async hooks).
The policy resolution itself runs in a worker thread (storage reads),
but the provider decision belongs on the loop with ``aauthorize()``.
*names* must already be canonical (``Skill.name``) — the caller
resolves the policy paths through the live registry off-loop first,
because the decision consumers (``_active_skills_for_paths``) check
the registry skill's declared name. Authorizing a path-derived name
here would miss the map and fall back to the synchronous provider
call from the worker thread (wrong API for loop-affine providers).
"""
if self._skill_authorization is None:
return None
candidates = {name for name in names if isinstance(name, str) and name}
if not candidates:
return None
from deerflow.authz.skill_filter import skill_activation_allowed_async
decisions: dict[str, bool] = {}
for name in sorted(candidates):
decisions[name] = await skill_activation_allowed_async(self._skill_authorization, name)
return decisions
def _resolve_policy_registry(self, paths: tuple[str, ...]) -> tuple[dict[str, Skill] | object, list[str]]:
"""Load the path-keyed registry and canonicalize the policy paths.
Blocking (skill-tree read): worker thread only. Returns the registry,
the canonical ``Skill.name`` for every resolvable path, or — when the
load fails — the ``_REGISTRY_LOAD_FAILED`` marker with no names. The
marker preserves the failure: the async caller hands it down so
``_active_skills_for_paths`` applies its fail-closed treatment instead
of retrying storage (a transient-failure retry that succeeds would
resolve skills missing from the decision map and fall back to the
synchronous provider API). Unresolvable paths yield no name:
``_active_skills_for_paths`` skips them before the activation check,
so no decision is needed for them.
"""
try:
from deerflow.skills.container_registry import build_container_path_registry
registry = build_container_path_registry(self._storage())
except Exception:
logger.exception("Failed to load active skills for allowed-tools policy")
return _REGISTRY_LOAD_FAILED, []
canonical: list[str] = []
for path in paths:
skill = registry.get(posixpath.normpath(path))
if skill is not None:
canonical.append(skill.name)
return registry, canonical
def _storage(self) -> SkillStorage:
if self._user_id is not None:
return get_or_new_user_skill_storage(self._user_id, app_config=self._app_config)
@ -73,13 +157,9 @@ class SkillToolPolicyMiddleware(AgentMiddleware[AgentState]):
return get_or_new_skill_storage(app_config=self._app_config)
return get_or_new_skill_storage()
def _active_policy(self, request: ModelRequest | ToolCallRequest) -> _PolicySignature:
context = getattr(getattr(request, "runtime", None), "context", None)
slash_path = read_slash_skill_source_path(context, owner_token=self._slash_source_owner_token)
if slash_path is not None:
return _POLICY_SOURCE_SLASH, (slash_path,)
paths: list[str] = []
@staticmethod
def _skill_context_entries(request: ModelRequest | ToolCallRequest) -> list:
"""Persisted ``skill_context`` entries from agent state (possibly empty)."""
state = getattr(request, "state", None)
if state is None:
state = {}
@ -93,28 +173,50 @@ class SkillToolPolicyMiddleware(AgentMiddleware[AgentState]):
if not isinstance(entries, (list, tuple)):
logger.warning("Invalid skill_context shape for skill tool policy: %s", type(entries).__name__)
entries = []
for entry in entries:
return list(entries)
def _active_policy(self, request: ModelRequest | ToolCallRequest) -> _PolicySignature:
context = getattr(getattr(request, "runtime", None), "context", None)
slash_path = read_slash_skill_source_path(context, owner_token=self._slash_source_owner_token)
if slash_path is not None:
return _POLICY_SOURCE_SLASH, (slash_path,)
paths: list[str] = []
for entry in self._skill_context_entries(request):
if isinstance(entry, dict) and isinstance(entry.get("path"), str):
paths.append(entry["path"])
if paths:
return _POLICY_SOURCE_SKILL_CONTEXT, tuple(paths)
return _POLICY_SOURCE_PASSIVE, ()
def _active_skills_for_paths(self, paths: tuple[str, ...]) -> tuple[list[Skill], bool]:
def _active_skills_for_paths(
self,
paths: tuple[str, ...],
*,
activation_decisions: dict[str, bool] | None = None,
registry: _RegistryArg = None,
) -> tuple[list[Skill], bool]:
if not paths:
return [], False
try:
storage = self._storage()
skills = storage.load_skills(enabled_only=False)
container_root = storage.get_container_root()
except Exception:
logger.exception("Failed to load active skills for allowed-tools policy")
# A real active reference exists but cannot be authorized. Signal a
# policy failure so callers retain only framework-safe tools.
if registry is _REGISTRY_LOAD_FAILED:
# The async prepass already attempted the load and failed. Do not
# retry here: a successful retry would resolve skills absent from
# the prepass-built decision map and silently fall back to the
# synchronous provider API from this worker thread. Preserve the
# failure with the same treatment as a first-load failure below.
return [], True
if registry is None:
try:
from deerflow.skills.container_registry import build_container_path_registry
registry = build_container_path_registry(self._storage())
except Exception:
logger.exception("Failed to load active skills for allowed-tools policy")
# A real active reference exists but cannot be authorized. Signal a
# policy failure so callers retain only framework-safe tools.
return [], True
registry = {posixpath.normpath(skill.get_container_file_path(container_root)): skill for skill in skills}
active: list[Skill] = []
seen: set[str] = set()
for path in paths:
@ -128,6 +230,13 @@ class SkillToolPolicyMiddleware(AgentMiddleware[AgentState]):
if self._available_skills is not None and skill.name not in self._available_skills:
logger.warning("Active skill is outside the agent allowlist for allowed-tools policy: %s", path)
continue
# Phase 3: skill_context persists across runs — an entry stamped
# under an earlier policy must not keep applying its allowed-tools
# declaration after the provider starts denying activation (same
# skip-and-continue treatment as a disabled or unallowlisted skill).
if not self._activation_allowed(skill.name, activation_decisions=activation_decisions):
logger.info("Active skill is denied by the skill:activate policy for allowed-tools policy: %s", path)
continue
if skill.name in seen:
continue
seen.add(skill.name)
@ -137,8 +246,14 @@ class SkillToolPolicyMiddleware(AgentMiddleware[AgentState]):
return [], True
return active, False
def _allowed_names_for_paths(self, paths: tuple[str, ...]) -> set[str] | None:
active_skills, policy_failed = self._active_skills_for_paths(paths)
def _allowed_names_for_paths(
self,
paths: tuple[str, ...],
*,
activation_decisions: dict[str, bool] | None = None,
registry: _RegistryArg = None,
) -> set[str] | None:
active_skills, policy_failed = self._active_skills_for_paths(paths, activation_decisions=activation_decisions, registry=registry)
if policy_failed:
return set(ALWAYS_AVAILABLE_BUILTIN_TOOL_NAMES)
allowed = allowed_tool_names_for_skills(active_skills)
@ -192,6 +307,8 @@ class SkillToolPolicyMiddleware(AgentMiddleware[AgentState]):
request: ModelRequest | ToolCallRequest,
*,
policy: _PolicySignature | None = None,
activation_decisions: dict[str, bool] | None = None,
registry: _RegistryArg = None,
) -> set[str] | None:
resolved_policy = self._active_policy(request) if policy is None else policy
_, paths = resolved_policy
@ -199,7 +316,7 @@ class SkillToolPolicyMiddleware(AgentMiddleware[AgentState]):
decision = self._read_policy_decision(context, resolved_policy)
if decision is not _MISSING_POLICY_DECISION:
return decision
return self._allowed_names_for_paths(paths)
return self._allowed_names_for_paths(paths, activation_decisions=activation_decisions, registry=registry)
def _filter_model_request(
self,
@ -207,10 +324,15 @@ class SkillToolPolicyMiddleware(AgentMiddleware[AgentState]):
*,
policy: _PolicySignature | None = None,
refresh_decision: bool = False,
activation_decisions: dict[str, bool] | None = None,
registry: _RegistryArg = None,
) -> ModelRequest:
resolved_policy = self._active_policy(request) if policy is None else policy
_, paths = resolved_policy
allowed = self._allowed_names_for_paths(paths) if refresh_decision else self._allowed_names(request, policy=resolved_policy)
if refresh_decision:
allowed = self._allowed_names_for_paths(paths, activation_decisions=activation_decisions, registry=registry)
else:
allowed = self._allowed_names(request, policy=resolved_policy, activation_decisions=activation_decisions, registry=registry)
if refresh_decision:
self._store_policy_decision(request, resolved_policy, allowed)
if allowed is None:
@ -325,11 +447,23 @@ class SkillToolPolicyMiddleware(AgentMiddleware[AgentState]):
if not paths:
self._store_policy_decision(request, policy, None)
return await handler(request)
# Three phases, each on the right executor: the registry lookup
# (skill-tree read) and the policy filter run in a worker thread,
# while the skill:activate re-authorization decisions are awaited on
# the event loop (aauthorize) for the *canonical* skill names the
# registry resolved — keying the map by path-derived names would miss
# and fall back to the sync provider API from the thread (wrong API
# for loop-affine providers). The loaded registry is reused by the
# filter so the whole hook costs one skill-tree scan.
registry, canonical_names = await asyncio.to_thread(self._resolve_policy_registry, paths)
activation_decisions = await self._collect_activation_decisions(canonical_names)
filtered = await asyncio.to_thread(
self._filter_model_request,
request,
policy=policy,
refresh_decision=True,
activation_decisions=activation_decisions,
registry=registry,
)
return await handler(filtered)
@ -357,7 +491,25 @@ class SkillToolPolicyMiddleware(AgentMiddleware[AgentState]):
policy = self._active_policy(request)
if not policy[1]:
return await handler(request)
allowed = await asyncio.to_thread(self._allowed_names, request, policy=policy)
# Reuse the current model step's cached decision when present — only a
# cache miss pays the three-phase resolution below (mirrors the sync
# wrap_tool_call, which reads the same cache inside _allowed_names).
cached = self._read_policy_decision(self._runtime_context(request), policy)
if cached is not _MISSING_POLICY_DECISION:
allowed = cached
else:
# Same three-phase shape as awrap_model_call: canonical names
# resolved off-loop, decisions awaited on the loop, blocking
# policy resolution in the thread with the registry reused.
registry, canonical_names = await asyncio.to_thread(self._resolve_policy_registry, policy[1])
activation_decisions = await self._collect_activation_decisions(canonical_names)
allowed = await asyncio.to_thread(
self._allowed_names,
request,
policy=policy,
activation_decisions=activation_decisions,
registry=registry,
)
blocked = self._blocked_tool_message(request, allowed=allowed)
if blocked is not None:
return blocked

View File

@ -1,5 +1,6 @@
"""Tool error handling middleware and shared runtime middleware builders."""
import asyncio
import logging
import secrets
from collections.abc import Awaitable, Callable
@ -13,7 +14,9 @@ from langgraph.prebuilt.tool_node import ToolCallRequest
from langgraph.types import Command
from deerflow.agents.middlewares.skill_context import (
SKILL_CONTEXT_DENIED_KEY,
SKILL_CONTEXT_ENTRY_KEY,
_skill_name_from_path,
_tool_call_id,
_tool_call_path,
build_skill_entry_metadata_from_read,
@ -59,9 +62,11 @@ def _stamp_task_exception_status(message: ToolMessage, *, tool_name: str, error:
class ToolErrorHandlingMiddleware(AgentMiddleware[AgentState]):
"""Convert tool exceptions into error ToolMessages so the run can continue."""
def __init__(self, *, app_config: AppConfig | None = None) -> None:
def __init__(self, *, app_config: AppConfig | None = None, skill_authorization=None, user_id: str | None = None) -> None:
super().__init__()
self._app_config = app_config
self._skill_authorization = skill_authorization
self._user_id = user_id
if app_config is None:
self._skill_read_tool_names = frozenset(DEFAULT_SKILL_FILE_READ_TOOL_NAMES)
self._skills_root = DEFAULT_SKILLS_CONTAINER_PATH
@ -69,6 +74,40 @@ class ToolErrorHandlingMiddleware(AgentMiddleware[AgentState]):
self._skill_read_tool_names = frozenset(app_config.summarization.skill_file_read_tool_names)
self._skills_root = app_config.skills.container_path
def _storage(self):
# Same resolution order as SkillActivationMiddleware/SkillToolPolicyMiddleware:
# the registry must be the user-scoped one when a user context exists,
# or per-user custom skills would never resolve to their Skill.name.
from deerflow.skills.storage import get_or_new_skill_storage, get_or_new_user_skill_storage
if self._user_id is not None:
return get_or_new_user_skill_storage(self._user_id, app_config=self._app_config)
if self._app_config is not None:
return get_or_new_skill_storage(app_config=self._app_config)
return get_or_new_skill_storage()
def _canonical_skill_name(self, skill_md_path: str) -> str:
"""Skill name the authorization layers speak for this SKILL.md path.
Resolves the container path against the live (user-scoped) registry so
the ``skill:activate`` target is the declared ``Skill.name`` — the
directory name and the declared name differ for bundled skills (e.g.
``skills/public/vercel-deploy-claimable`` declares ``vercel-deploy``),
and Layer 1 / slash / describe all authorize the declared name.
Blocking (skill-tree read): sync handlers and worker threads only.
Falls back to the path-derived name when the registry can't resolve
the path (uninstalled skill) — downstream consumers skip those paths
against the same registry anyway.
"""
from deerflow.skills.container_registry import build_container_path_registry, canonical_skill_name
try:
registry = build_container_path_registry(self._storage())
except Exception:
logger.warning("Failed to load the skill registry while resolving a skill read", exc_info=True)
return _skill_name_from_path(skill_md_path)
return canonical_skill_name(registry, skill_md_path) or _skill_name_from_path(skill_md_path)
def _build_error_message(self, request: ToolCallRequest, exc: Exception) -> ToolMessage:
tool_name = str(request.tool_call.get("name") or "unknown_tool")
tool_call_id = str(request.tool_call.get("id") or _MISSING_TOOL_CALL_ID)
@ -96,7 +135,19 @@ class ToolErrorHandlingMiddleware(AgentMiddleware[AgentState]):
request: ToolCallRequest,
*,
tool_name: str,
activation_allowed: bool | None = None,
skill_name: str | None = None,
) -> ToolMessage:
"""Stamp the skill-read metadata for one completed read tool result.
*activation_allowed* carries a ``skill:activate`` decision already
resolved by the caller — the async path awaits ``aauthorize()`` on the
event loop before calling this. ``None`` (the sync-path default)
resolves the decision here with the synchronous ``authorize()``.
*skill_name* is the already-canonicalized authorization target (the
async caller resolves it in a worker thread); when omitted, the sync
path resolves it here.
"""
if tool_name not in self._skill_read_tool_names:
return message
if getattr(message, "status", "success") == "error":
@ -113,6 +164,34 @@ class ToolErrorHandlingMiddleware(AgentMiddleware[AgentState]):
entry = build_skill_entry_metadata_from_read(path, content, skills_root=self._skills_root)
if entry is None:
return message
# Phase 3: a completed SKILL.md read only *activates* a skill (durable
# skill_context entry, allowed-tools policy, autonomous secret binding)
# when the action-scoped ``skill:activate`` decision allows it. The
# catalog/visibility layer (Layer 1) may expose the file itself — the
# action decision is what gates the autonomous activation path the
# model drives through describe_skill + read_file. A denied read gets
# the denial marker instead of entry metadata, so extract_skills skips
# it without the "missing skill read metadata" warning.
if self._skill_authorization is not None:
if skill_name is None:
skill_name = self._canonical_skill_name(entry["path"])
if activation_allowed is None:
from deerflow.authz.skill_filter import skill_activation_allowed
activation_allowed = skill_activation_allowed(self._skill_authorization, skill_name)
elif activation_allowed is None:
# Authorization disabled — visibility (Layer 1) already decided
# membership, so the read activates unconditionally.
activation_allowed = True
if not activation_allowed:
logger.info(
"Skill file read for '%s' did not activate it: skill:activate denied",
skill_name or _skill_name_from_path(entry["path"]),
)
existing = dict(message.additional_kwargs or {})
existing[SKILL_CONTEXT_DENIED_KEY] = True
message.additional_kwargs = existing
return message
existing = dict(message.additional_kwargs or {})
existing[SKILL_CONTEXT_ENTRY_KEY] = dict(entry)
args = request.tool_call.get("args") or {}
@ -127,6 +206,74 @@ class ToolErrorHandlingMiddleware(AgentMiddleware[AgentState]):
message.additional_kwargs = existing
return message
async def _amaybe_stamp(self, result: ToolMessage | Command, request: ToolCallRequest) -> ToolMessage | Command:
"""Async twin of ``_maybe_stamp`` for ``awrap_tool_call``.
Mirrors the sync structure (plain ToolMessage or Command-carried
results, tool_call_id matching, untrusted tool-returned kwargs
scrubbed before stamping) while resolving the canonical authorization
target and the ``skill:activate`` decision off the event loop in the
right order: the registry lookup (skill-tree read) goes to a worker
thread, the provider call is awaited on the loop with
``aauthorize()`` — a synchronous ``authorize()`` here would hand
loop-affine providers the wrong API.
"""
tool_name = str(request.tool_call.get("name") or "")
tool_call_id = _tool_call_id(request.tool_call)
async def stamp(message: ToolMessage) -> None:
# Tool-returned kwargs are untrusted. Only this middleware may add
# skill evidence after checking the configured producer and path.
existing = dict(message.additional_kwargs or {})
existing.pop(SKILL_CONTEXT_ENTRY_KEY, None)
existing.pop(SKILL_USAGE_KEY, None)
message.additional_kwargs = existing
if tool_call_id is None or str(message.tool_call_id) == tool_call_id:
allowed: bool | None = None
skill_name: str | None = None
probe = self._resolve_skill_read_entry(message, request, tool_name=tool_name)
if probe is not None and self._skill_authorization is not None:
from deerflow.authz.skill_filter import skill_activation_allowed_async
skill_name = await asyncio.to_thread(self._canonical_skill_name, probe["path"])
allowed = await skill_activation_allowed_async(self._skill_authorization, skill_name)
self._stamp_skill_read_metadata(message, request, tool_name=tool_name, activation_allowed=allowed, skill_name=skill_name)
if isinstance(result, ToolMessage):
await stamp(result)
return result
update = getattr(result, "update", None)
if not isinstance(update, dict):
return result
messages = update.get("messages")
if isinstance(messages, ToolMessage):
await stamp(messages)
elif isinstance(messages, (list, tuple)):
for message in messages:
if isinstance(message, ToolMessage):
await stamp(message)
return result
def _resolve_skill_read_entry(
self,
message: ToolMessage,
request: ToolCallRequest,
*,
tool_name: str,
) -> dict | None:
"""Resolve the skill_context entry a completed read would stamp, if any."""
if tool_name not in self._skill_read_tool_names:
return None
if getattr(message, "status", "success") == "error":
return None
content = message.content if isinstance(message.content, str) else None
if content is None:
return None
path = _tool_call_path(request.tool_call)
if path is None:
return None
return build_skill_entry_metadata_from_read(path, content, skills_root=self._skills_root)
def _maybe_stamp(self, result: ToolMessage | Command, request: ToolCallRequest) -> ToolMessage | Command:
"""Apply producer-bound metadata for tool results that need it."""
tool_name = str(request.tool_call.get("name") or "")
@ -190,7 +337,7 @@ class ToolErrorHandlingMiddleware(AgentMiddleware[AgentState]):
except Exception as exc:
logger.exception("Tool execution failed (async): name=%s id=%s", request.tool_call.get("name"), request.tool_call.get("id"))
return self._build_error_message(request, exc)
return self._maybe_stamp(
return await self._amaybe_stamp(
normalize_tool_result(result, tool_call_id=str(request.tool_call.get("id") or "")),
request,
)
@ -207,6 +354,8 @@ def _build_runtime_middlewares(
authorization_infrastructure_tool_names: frozenset[str] = frozenset(),
available_skills: set[str] | None = None,
owns_agent_skill_projection: bool = True,
skill_authorization=None,
user_id: str | None = None,
) -> list[AgentMiddleware]:
"""Build shared base middlewares for agent execution."""
from deerflow.agents.middlewares.input_sanitization_middleware import InputSanitizationMiddleware
@ -362,7 +511,7 @@ def _build_runtime_middlewares(
tail.append(ToolProgressMiddleware.from_config(tool_progress_config))
tail.append(ToolErrorHandlingMiddleware(app_config=app_config))
tail.append(ToolErrorHandlingMiddleware(app_config=app_config, skill_authorization=skill_authorization, user_id=user_id))
# Artifact capture is a `before_model` hook that reads state messages, so
# its position in the tool-execution wrap chain is functionally irrelevant:
# it always sees the normalized results stored in state, and error results
@ -393,6 +542,8 @@ def build_lead_runtime_middlewares(
deferred_setup: "DeferredToolSetup | None" = None,
available_skills: set[str] | None = None,
owns_agent_skill_projection: bool = True,
skill_authorization=None,
user_id: str | None = None,
) -> list[AgentMiddleware]:
"""Middlewares shared by lead agent runtime before lead-only middlewares."""
return _build_runtime_middlewares(
@ -406,6 +557,8 @@ def build_lead_runtime_middlewares(
authorization_provider=authorization_provider,
available_skills=available_skills,
owns_agent_skill_projection=owns_agent_skill_projection,
skill_authorization=skill_authorization,
user_id=user_id,
authorization_infrastructure_tool_names=(frozenset({deferred_setup.tool_search_tool.name}) if authorization_provider is not None and deferred_setup is not None and deferred_setup.tool_search_tool is not None else frozenset()),
)
@ -421,6 +574,7 @@ def build_subagent_runtime_middlewares(
available_skills: set[str] | None = None,
user_id: str | None = None,
authorization_provider=None,
skill_authorization=None,
extensions=None,
) -> list[AgentMiddleware]:
"""Middlewares shared by subagent runtime before subagent-only middlewares."""
@ -444,6 +598,8 @@ def build_subagent_runtime_middlewares(
authorization_provider=authorization_provider,
authorization_infrastructure_tool_names=(frozenset({deferred_setup.tool_search_tool.name}) if authorization_provider is not None and deferred_setup is not None and deferred_setup.tool_search_tool is not None else frozenset()),
owns_agent_skill_projection=False,
skill_authorization=skill_authorization,
user_id=user_id,
)
# Enabled/configured skills are discoverable metadata, not automatically
@ -460,6 +616,7 @@ def build_subagent_runtime_middlewares(
app_config=app_config,
user_id=user_id,
slash_source_owner_token=slash_source_owner_token,
skill_authorization=skill_authorization,
)
)
if deferred_setup is not None and deferred_setup.deferred_names:
@ -472,6 +629,9 @@ def build_subagent_runtime_middlewares(
app_config=app_config,
user_id=user_id,
slash_source_owner_token=slash_source_owner_token,
# Persisted skill_context entries are re-authorized against the
# skill:activate decision before their allowed-tools apply.
skill_authorization=skill_authorization,
)
)
@ -577,6 +737,11 @@ def build_subagent_runtime_middlewares(
skill_file_read_tool_names=app_config.summarization.skill_file_read_tool_names,
inject_tool_artifacts=app_config.tool_artifacts.enabled and app_config.tool_artifacts.inject_model_context,
pii_redaction_config=getattr(app_config, "pii_redaction", None),
# The rendered "Active skills" reminder hides entries the provider
# denies; decisions are published per step by the activation
# middleware under the shared chain token.
skill_authorization=skill_authorization,
entry_decisions_owner_token=slash_source_owner_token,
)
)

View File

@ -1,7 +1,7 @@
"""Pluggable fine-grained authorization (resource-level RBAC and beyond)."""
from deerflow.authz.adapter import GuardrailAuthorizationAdapter
from deerflow.authz.enforcement import filter_tools_by_authorization
from deerflow.authz.enforcement import filter_resources_by_authorization, filter_tools_by_authorization
from deerflow.authz.plugin_authz import (
PluginAuthorizationError,
aenforce_plugin_action,
@ -23,6 +23,7 @@ from deerflow.authz.provider import AuthorizationProvider, AuthzDecision, AuthzR
from deerflow.authz.rbac import RbacAuthorizationProvider
from deerflow.authz.runtime import resolve_authorization_provider
from deerflow.authz.sandbox_authz import authorize_sandbox_execution
from deerflow.authz.skill_filter import filter_available_skills_by_authorization
from deerflow.authz.tool_filter import apply_tool_authorization
__all__ = [
@ -45,6 +46,8 @@ __all__ = [
"build_principal_from_context",
"enforce_plugin_action",
"enforce_plugin_management",
"filter_available_skills_by_authorization",
"filter_resources_by_authorization",
"filter_tools_by_authorization",
"normalize_authz_attributes",
"plugin_action_target",

View File

@ -4,6 +4,7 @@ from __future__ import annotations
import logging
from collections.abc import Sequence
from typing import Protocol
from langchain_core.tools import BaseTool
@ -12,6 +13,12 @@ from deerflow.authz.provider import AuthorizationProvider, Principal
logger = logging.getLogger(__name__)
class _Named(Protocol):
"""Minimal protocol for resources identified by a ``name`` attribute."""
name: str
def filter_tools_by_authorization(
tools: Sequence[BaseTool],
*,
@ -40,3 +47,36 @@ def filter_tools_by_authorization(
allowed_names = set(allowed)
return [tool for tool in original_tools if tool.name in allowed_names]
def filter_resources_by_authorization(
resources: Sequence[_Named],
*,
resource_type: str,
provider: AuthorizationProvider | None,
principal: Principal,
fail_closed: bool,
) -> list[_Named]:
"""Return the policy-visible subset of *resources* without changing order.
Generic batch filter for any resource identified by a ``name`` attribute
(skills, models, etc.). Mirrors ``filter_tools_by_authorization`` but is
not coupled to ``BaseTool``. Provider errors and malformed filter results
deny every resource when ``fail_closed`` is true; an explicitly configured
fail-open policy preserves the original set.
"""
original = list(resources)
if provider is None:
return original
candidates = [r.name for r in original]
try:
allowed = provider.filter_resources(principal, resource_type, candidates)
if not isinstance(allowed, list) or any(not isinstance(name, str) for name in allowed):
raise TypeError("AuthorizationProvider.filter_resources must return list[str]")
except Exception:
logger.exception("Authorization provider failed while filtering %s", resource_type)
return [] if fail_closed else original
allowed_names = set(allowed)
return [r for r in original if r.name in allowed_names]

View File

@ -0,0 +1,237 @@
"""Convenience wrapper for Layer 1 skill authorization filtering.
Combines provider resolution, Principal construction, and skill filtering into
a single call so the assembly paths (lead agent, subagent executor) stay
one-liners. Mirrors ``apply_tool_authorization`` from ``tool_filter.py``.
"""
from __future__ import annotations
import logging
from collections.abc import Mapping
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from deerflow.authz.principal import build_principal_from_context
from deerflow.authz.provider import AuthzDecision, AuthzRequest
from deerflow.authz.runtime import resolve_authorization_provider
from deerflow.config.app_config import AppConfig
if TYPE_CHECKING:
from deerflow.authz.provider import AuthorizationProvider
logger = logging.getLogger(__name__)
@dataclass(frozen=True, slots=True)
class ResolvedSkillAuthorization:
"""Provider + principal resolved once per agent assembly.
Shared by the Layer 1 visibility filter above and the runtime
``skill:activate`` action check in ``SkillActivationMiddleware`` so both
layers see the same provider instance and identity without resolving
twice. ``fail_closed`` is the provider-error policy from config.
"""
provider: AuthorizationProvider
principal: Any
fail_closed: bool
def skill_activation_allowed(
authorization: ResolvedSkillAuthorization | None,
skill_name: str,
) -> bool:
"""Action-scoped ``skill:activate`` decision for one skill name.
Shared by every runtime activation gate — explicit slash activation
(``SkillActivationMiddleware``), ``describe_skill``, and the skill-file
load that records ``skill_context`` — so all paths apply the same decision
and the same provider-error policy. ``_available_skills``-style visibility
sets are Layer 1 (action-agnostic ``filter_resources``); an action-aware
custom provider may expose a skill there while denying its activation, so
each activation path must additionally consult this check. ``None``
(authorization disabled) allows — visibility already decided membership.
Mirrors ``_authorize_model_name``'s second ``authorize("model", "use")``
check. Provider errors follow the configured fail-closed / fail-open
policy.
"""
if authorization is None:
return True
try:
decision = authorization.provider.authorize(AuthzRequest(principal=authorization.principal, resource="skill", action="activate", target=skill_name))
if not isinstance(decision, AuthzDecision):
raise TypeError("AuthorizationProvider.authorize must return AuthzDecision")
return decision.allow
except Exception:
logger.warning("Authorization provider failed while checking skill:activate for '%s'", skill_name, exc_info=True)
return not authorization.fail_closed
async def skill_activation_allowed_async(
authorization: ResolvedSkillAuthorization | None,
skill_name: str,
) -> bool:
"""Async counterpart of :func:`skill_activation_allowed`.
Uses ``provider.aauthorize()`` so loop-affine custom providers (clients
bound to the running event loop) receive the async API on async execution
paths, mirroring ``authorize_sandbox_execution_async``. Same decision
semantics and fail-closed / fail-open policy as the sync version.
"""
if authorization is None:
return True
try:
decision = await authorization.provider.aauthorize(AuthzRequest(principal=authorization.principal, resource="skill", action="activate", target=skill_name))
if not isinstance(decision, AuthzDecision):
raise TypeError("AuthorizationProvider.aauthorize must return AuthzDecision")
return decision.allow
except Exception:
logger.warning("Authorization provider failed while checking skill:activate for '%s'", skill_name, exc_info=True)
return not authorization.fail_closed
def resolve_skill_authorization(
context: Mapping[str, Any],
app_config: AppConfig,
) -> ResolvedSkillAuthorization | None:
"""Resolve the skill authorization context, or ``None`` when disabled.
``None`` means "authorization is disabled or no provider is configured" —
callers treat that as "no extra enforcement" (the pre-authorization
behavior). Mock-friendly in the same style as the filter below: a config
object without a real ``AuthorizationConfig`` also yields ``None``.
"""
authz_config = getattr(app_config, "authorization", None)
if authz_config is None or getattr(authz_config, "enabled", None) is not True:
return None
provider = resolve_authorization_provider(authz_config)
if provider is None:
return None
principal = build_principal_from_context(context, default_role=authz_config.default_role)
return ResolvedSkillAuthorization(
provider=provider,
principal=principal,
fail_closed=bool(getattr(authz_config, "fail_closed", False)),
)
def filter_available_skills_by_authorization(
available_skills: set[str] | None,
*,
context: Mapping[str, Any],
app_config: AppConfig,
user_id: str | None = None,
candidate_skill_names: list[str] | None = None,
authorization: ResolvedSkillAuthorization | None = None,
) -> set[str] | None:
"""Filter a skill-name allowlist by the provider's ``"skill"`` policy.
This is the Layer 1 entry point for the lead-agent and subagent assembly
paths. It operates on the ``available_skills`` name set (the agent-config
allowlist) rather than on loaded ``Skill`` objects, so it runs before any
disk I/O and constrains both the catalog (``describe_skill``) and the
slash-activation middleware (which checks the same set).
- ``available_skills=None`` means "no agent-level allowlist" (all enabled
skills). When authorization is enabled, this resolves the full enabled
skill set from config and filters it; the result is a constrained set
(or ``None`` if authorization is disabled).
- ``available_skills=set()`` (empty) means "no skills for this agent" and
is preserved as-is (the agent already allows none).
*user_id* threads the per-user identity into the candidate resolution so
per-user custom skills (``UserScopedSkillStorage``) are included in the
universe the provider filters — matching what the activation path loads.
When omitted, only the process-global (public) skills are candidates.
*candidate_skill_names* lets a caller that has already loaded skills from
disk (e.g. the subagent executor) pass the pre-resolved name list, avoiding
a redundant ``load_skills`` call inside ``_all_configured_skill_names``.
Only consulted when ``available_skills is None``.
When ``authorization.enabled`` is false, returns *available_skills*
unchanged (no-op).
"""
# Guard against Mock/SimpleNamespace app_config objects in tests that
# don't carry a real AuthorizationConfig. getattr avoids AttributeError
# and the ``is not True`` identity check avoids truthy Mock attributes.
if authorization is None:
authorization = resolve_skill_authorization(context, app_config)
if authorization is None:
return available_skills
fail_closed = authorization.fail_closed
principal = authorization.principal
# If the agent already allows no skills, nothing to filter.
if available_skills is not None and len(available_skills) == 0:
return available_skills
# Resolve the candidate set: the agent allowlist (if set), or all known
# skill names from config (so authorization constrains the "no allowlist"
# case too). When ``available_skills is None`` and the configured-skill
# resolution fails (storage I/O error), we must NOT silently return ``None``
# — that would bypass authorization entirely (every skill becomes
# activatable/loadable). Instead we fall through to the same fail-closed /
# fail-open handling as a provider error below.
if available_skills is not None:
candidates = sorted(available_skills)
elif candidate_skill_names is not None:
# Caller already loaded skills from disk (e.g. subagent executor) —
# reuse the names instead of calling load_skills again.
candidates = candidate_skill_names
else:
try:
candidates = _all_configured_skill_names(app_config, user_id=user_id)
except Exception:
logger.warning("Could not resolve configured skill names for authorization filtering", exc_info=True)
return set() if fail_closed else available_skills
if not candidates:
# Genuinely no skills configured (or agent allowlist is empty after
# filtering) — ``None`` here is safe because there is nothing to authorize.
return available_skills
try:
allowed = authorization.provider.filter_resources(principal, "skill", candidates)
if not isinstance(allowed, list) or any(not isinstance(n, str) for n in allowed):
raise TypeError("AuthorizationProvider.filter_resources must return list[str]")
except Exception:
logger.warning("Authorization provider failed while filtering available skills", exc_info=True)
return set() if fail_closed else available_skills
# Defensive intersection with the candidate set: the provider contract says
# results must be a subset of candidates, but we only validated list[str].
# A buggy custom provider could otherwise inject extra skill names and
# expand an explicit agent allowlist. Mirrors filter_resources_by_authorization.
allowed_names = set(allowed)
return {name for name in candidates if name in allowed_names}
def _all_configured_skill_names(app_config: AppConfig, *, user_id: str | None = None) -> list[str]:
"""Return all configured skill names (public + per-user custom).
This is only needed when ``available_skills`` is ``None`` (no agent-level
allowlist) and authorization is enabled — we need the full candidate set
to filter. Returns an empty list if no skills are configured. Raises on
storage/I/O errors so the caller can apply fail-closed / fail-open semantics
(a swallowed error here would silently bypass authorization).
When *user_id* is provided, uses the per-user ``UserScopedSkillStorage`` so
custom skills under ``{base}/users/{user_id}/skills/custom/`` are included —
matching the universe the activation path loads. Without it, falls back to
the process-global storage (public skills only).
"""
if user_id is not None:
from deerflow.skills.storage import get_or_new_user_skill_storage
storage = get_or_new_user_skill_storage(user_id, app_config=app_config)
else:
from deerflow.skills.storage import get_or_new_skill_storage
storage = get_or_new_skill_storage(app_config=app_config)
skills = storage.load_skills(enabled_only=True)
return [s.name for s in skills]

View File

@ -380,6 +380,45 @@ class DeerFlowClient:
if model_name is None and self._app_config.models:
model_name = self._app_config.models[0].name
model_name = _authorize_model_name(model_name, context=cfg, app_config=self._app_config)
# Phase 3: enforce skill authorization (Layer 1) on the embedded path
# too, mirroring ``_make_lead_agent`` (agent.py:675). Without this, a
# caller building the agent via ``DeerFlowClient(available_skills=...)``
# bypasses the role's ``skills`` policy: ``SkillActivationMiddleware``
# and the catalog/prompt would see the unfiltered set. ``self._available_skills``
# is left untouched (it feeds the cache key at line 282); the filtered
# result is a local used for assembly only.
from deerflow.authz.skill_filter import filter_available_skills_by_authorization, resolve_skill_authorization
# One effective identity for the whole skill surface: the filter's
# candidate universe, the candidate pre-load's cache bucket, and the
# middleware's user-scoped storage must all resolve the same user, or
# per-user custom skills visible to the middleware would be missing
# from the filtered set (and vice versa). Resolved before anything
# consumes a user id below.
effective_user_id = cfg.get("user_id") or get_effective_user_id()
skill_authorization = resolve_skill_authorization(cfg, self._app_config)
# Resolve the filter's candidate names through the cached catalog
# loader (same source and same user bucket as ``skills_list`` below)
# instead of letting the filter rescan storage on every build; only
# relevant when no agent-level allowlist is set.
candidate_skill_names = None
if self._available_skills is None and skill_authorization is not None:
try:
candidate_skill_names = [s.name for s in get_enabled_skills_for_config(self._app_config, user_id=effective_user_id)]
except Exception:
logger.warning("Failed to pre-load enabled skills for authorization candidates", exc_info=True)
# Leave None: the filter resolves candidates itself and applies
# its fail-closed semantics when that resolution also fails.
available_skills = filter_available_skills_by_authorization(
self._available_skills,
context=cfg,
app_config=self._app_config,
user_id=effective_user_id,
candidate_skill_names=candidate_skill_names,
authorization=skill_authorization,
)
subagent_enabled = cfg.get("subagent_enabled", False)
from deerflow.config.subagents_config import effective_subagent_concurrency, effective_total_subagents_per_run
@ -403,13 +442,16 @@ class DeerFlowClient:
# Add framework-provided tools before authorization so Layer 1 sees
# every capability that can become model-visible.
skills_list = get_enabled_skills_for_config(self._app_config)
if self._available_skills is not None:
skills_list = [s for s in skills_list if s.name in self._available_skills]
# Same effective user as the filter/candidates above so the catalog
# shares the per-user cache bucket and the same skill universe.
skills_list = get_enabled_skills_for_config(self._app_config, user_id=effective_user_id)
if available_skills is not None:
skills_list = [s for s in skills_list if s.name in available_skills]
skill_setup = build_skill_search_setup(
skills_list,
enabled=self._app_config.skills.deferred_discovery,
container_base_path=self._app_config.skills.container_path,
skill_authorization=skill_authorization,
)
from deerflow.agents.task_continuity.tools import append_task_continuity_tools
@ -450,7 +492,7 @@ class DeerFlowClient:
config,
model_name=model_name,
agent_name=self._agent_name,
available_skills=self._available_skills,
available_skills=available_skills,
memory_enabled=memory_enabled,
custom_middlewares=self._middlewares,
app_config=self._app_config,
@ -458,6 +500,7 @@ class DeerFlowClient:
mcp_routing_middleware=mcp_routing_middleware,
user_id=effective_user_id,
authorization_provider=_authz_provider,
skill_authorization=skill_authorization,
subagent_execution_capacity=subagent_execution_capacity,
),
self._checkpoint_channel_mode,
@ -468,7 +511,7 @@ class DeerFlowClient:
max_concurrent_subagents=max_concurrent_subagents,
max_total_subagents=max_total_subagents,
agent_name=self._agent_name,
available_skills=self._available_skills,
available_skills=available_skills,
app_config=self._app_config,
deferred_names=deferred_setup.deferred_names,
mcp_routing_hints_section=mcp_routing_hints_section,

View File

@ -103,6 +103,44 @@ def read_slash_skill_source_path(context: Any, *, owner_token: str) -> str | Non
return path if isinstance(path, str) and path else None
# Reserved run-context key holding the per-model-step ``skill:activate``
# decisions for persisted ``skill_context`` entry paths, keyed by normalized
# container SKILL.md path. Published by the skill-activation middleware (async
# hook: reused from the prepass snapshot; sync hook: one scan + sync
# authorize) and consumed by the durable-context renderer so the model-visible
# "Active skills" reminder stops advertising skills the provider now denies.
# Names only — never secret values. Authenticated by the chain-local owner
# token, same contract as the slash source above.
_SKILL_ENTRY_DECISIONS_KEY = "__skill_entry_activation_decisions"
def write_skill_entry_decisions(context: Any, decisions: dict[str, bool], *, owner_token: str) -> None:
"""Publish per-step entry activation decisions for downstream renderers."""
if isinstance(context, dict) and isinstance(decisions, dict) and isinstance(owner_token, str) and owner_token:
context[_SKILL_ENTRY_DECISIONS_KEY] = {"decisions": dict(decisions), "owner_token": owner_token}
def read_skill_entry_decisions(context: Any, *, owner_token: str) -> dict[str, bool] | None:
"""Return the authenticated entry decisions, or ``None`` when unpublished.
``None`` means "no decision published this step" (authorization disabled,
no entries, or a foreign/malformed carrier) — consumers then render the
entries unchanged, matching the absent-is-permissive contract of the
other run-context carriers.
"""
if not isinstance(context, dict):
return None
carrier = context.get(_SKILL_ENTRY_DECISIONS_KEY)
if not isinstance(carrier, dict):
return None
if not isinstance(owner_token, str) or not owner_token or carrier.get("owner_token") != owner_token:
return None
decisions = carrier.get("decisions")
if not isinstance(decisions, dict) or not all(isinstance(k, str) and isinstance(v, bool) for k, v in decisions.items()):
return None
return decisions
# Private run-context keys the skill-activation middleware uses to carry secret
# bindings across a run. Only ``secrets`` / ``__active_skill_secrets`` hold
# secret values; the slash source holds a middleware-chain owner token, while
@ -131,6 +169,7 @@ REDACTED_CONTEXT_KEYS = frozenset(
_SECRETS_BINDING_AUDIT_KEY,
_SLASH_SKILL_ACTIVATION_RUN_KEY,
SKILL_TOOL_POLICY_DECISION_CONTEXT_KEY,
_SKILL_ENTRY_DECISIONS_KEY,
}
)

View File

@ -0,0 +1,40 @@
"""Map container SKILL.md paths to the live registry's canonical skills.
The directory a skill lives in (``relative_path``, e.g.
``vercel-deploy-claimable``) and the name its SKILL.md declares
(``Skill.name``, e.g. ``vercel-deploy``) are deliberately independent: the
authorization layers (Layer 1 filtering, slash activation, ``describe_skill``)
all speak the declared ``Skill.name``, so any path-derived lookup must be
resolved through this registry before it is compared against — or sent to — an
authorization decision.
"""
from __future__ import annotations
import posixpath
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from deerflow.skills.types import Skill
def build_container_path_registry(storage) -> dict[str, Skill]:
"""Build the live registry keyed by normalized container SKILL.md path.
Blocking (full skill-tree read): call from sync handlers or a worker
thread, never the event loop. ``enabled_only=False`` on purpose — the
activation decision is authorization's to make, not the enabled flag's.
"""
container_root = storage.get_container_root()
return {posixpath.normpath(skill.get_container_file_path(container_root)): skill for skill in storage.load_skills(enabled_only=False)}
def canonical_skill_name(registry: dict[str, Skill], skill_md_path: str) -> str | None:
"""Resolve a container SKILL.md path to the registry skill's declared name.
``None`` for a path the registry does not know (uninstalled, forged, or
failed to parse) — callers decide their own fallback, typically the
path-derived directory name for display or a skip for policy.
"""
skill = registry.get(posixpath.normpath(skill_md_path))
return skill.name if skill is not None else None

View File

@ -17,7 +17,7 @@ from dataclasses import dataclass
from typing import TYPE_CHECKING, Annotated
from langchain_core.messages import ToolMessage
from langchain_core.tools import InjectedToolCallId, tool
from langchain_core.tools import InjectedToolCallId
from langgraph.types import Command
if TYPE_CHECKING:
@ -53,15 +53,70 @@ def build_describe_skill_tool(
catalog: SkillCatalog,
*,
container_base_path: str = DEFAULT_SKILLS_CONTAINER_PATH,
skill_authorization=None,
) -> BaseTool:
"""Build the ``describe_skill`` tool as a closure over *catalog*.
The returned tool is a plain ``@tool``-decorated function that searches the
catalog and returns a ``Command`` wrapping a ``ToolMessage``. No graph state
mutation is needed (unlike ``tool_search`` which promotes deferred tools).
The returned tool carries both a sync function and a coroutine: sync
invocation (``invoke``, e.g. tests and sync chains) gates matches with the
provider's synchronous ``authorize()``, while async invocation (``ainvoke``,
the path async agent execution takes) awaits ``aauthorize()`` on the event
loop — loop-affine custom providers must never see the sync API. No graph
state mutation is needed (unlike ``tool_search`` which promotes deferred
tools).
*skill_authorization* (a ``ResolvedSkillAuthorization`` from
``deerflow.authz.skill_filter``) gates describe results on the action-scoped
``skill:activate`` decision: the catalog holds the Layer 1 visibility set
(action-agnostic), so an action-aware provider may list a skill the model
must still not be able to load. Denied skills are omitted from the output
(indistinguishable from a non-match, so the reason is not leaked) with the
provider's configured fail-closed / fail-open policy on errors. ``None``
(authorization disabled) describes everything in the catalog.
"""
@tool
def _log_omissions(omitted: int) -> None:
if omitted:
logger.info("describe_skill: omitted %d skill(s) denied by the skill:activate policy", omitted)
def _filter_activation_denied(matched: list) -> list:
if skill_authorization is None:
return matched
from deerflow.authz.skill_filter import skill_activation_allowed
kept = [s for s in matched if skill_activation_allowed(skill_authorization, s.name)]
_log_omissions(len(matched) - len(kept))
return kept
async def _afilter_activation_denied(matched: list) -> list:
if skill_authorization is None:
return matched
from deerflow.authz.skill_filter import skill_activation_allowed_async
kept = []
for s in matched:
if await skill_activation_allowed_async(skill_authorization, s.name):
kept.append(s)
_log_omissions(len(matched) - len(kept))
return kept
def _command(matched: list, name: str, tool_call_id: str) -> Command:
if not matched:
content = f"No skills matched: {name}"
else:
content = _render_skill_metadata(matched, container_base_path)
return Command(
update={
"messages": [
ToolMessage(
content=content,
tool_call_id=tool_call_id,
name="describe_skill",
)
],
}
)
def describe_skill(
name: str,
tool_call_id: Annotated[str, InjectedToolCallId],
@ -79,25 +134,23 @@ def build_describe_skill_tool(
- "chart visualization" -- keyword search, best matches (up to 5)
- "+podcast gen" -- require "podcast" in the name, rank by remaining terms (up to 5)
"""
matched = catalog.search(name)
if not matched:
content = f"No skills matched: {name}"
else:
content = _render_skill_metadata(matched, container_base_path)
return _command(_filter_activation_denied(catalog.search(name)), name, tool_call_id)
return Command(
update={
"messages": [
ToolMessage(
content=content,
tool_call_id=tool_call_id,
name="describe_skill",
)
],
}
)
async def adescribe_skill(
name: str,
tool_call_id: Annotated[str, InjectedToolCallId],
) -> Command:
return _command(await _afilter_activation_denied(catalog.search(name)), name, tool_call_id)
return describe_skill
from langchain_core.tools import StructuredTool
return StructuredTool.from_function(
func=describe_skill,
coroutine=adescribe_skill,
name="describe_skill",
description=describe_skill.__doc__,
parse_docstring=False,
)
def build_skill_search_setup(
@ -105,12 +158,15 @@ def build_skill_search_setup(
*,
enabled: bool,
container_base_path: str = DEFAULT_SKILLS_CONTAINER_PATH,
skill_authorization=None,
) -> SkillSearchSetup:
"""Build the skill search setup from a filtered skill list.
Mirrors ``build_deferred_tool_setup`` from ``tool_search.py``.
Returns an empty setup when *enabled* is ``False`` or *skills* is empty.
*skill_authorization* is forwarded to the describe tool (see
``build_describe_skill_tool``).
"""
if not enabled or not skills:
return SkillSearchSetup(None, frozenset())
@ -120,6 +176,7 @@ def build_skill_search_setup(
describe_skill_tool=build_describe_skill_tool(
catalog,
container_base_path=container_base_path,
skill_authorization=skill_authorization,
),
skill_names=catalog.names,
)

View File

@ -941,6 +941,11 @@ class SubagentExecutor:
# not just the first — because the v2 contract advertises more than one
# cap reason.
self._stop_reason_middlewares: list[Any] = []
# The one skill-authorization context for this execution, resolved
# lazily by ``_resolve_skill_authorization`` and shared by the Layer 1
# filter and every runtime ``skill:activate`` check (activation
# middleware, describe gate, skill-read stamping).
self._skill_authorization: Any | None = None
# LangGraph super-step budget that buys ``config.max_turns`` turns,
# resolved in ``_create_agent`` once the middleware chain — and with it
# the compiled graph's per-turn node count — is known. Stays ``None``
@ -1008,6 +1013,17 @@ class SubagentExecutor:
authz_provider = getattr(self, "_authz_provider", None)
if authz_provider is not None:
middleware_kwargs["authorization_provider"] = authz_provider
# Phase 3 (Layer 2): arm the runtime ``skill:activate`` check on the
# subagent chain too — a delegated task is a plain HumanMessage, so
# task text containing /skill-name reaches the activation middleware.
# Resolved through the same memoized helper the Layer 1 filter used
# (``_resolve_skill_authorization``): provider factories are
# deliberately uncached, so resolving independently here could hand the
# runtime checks a different provider instance (and policy snapshot)
# than the filter that built ``_available_skill_names``.
skill_authorization = self._resolve_skill_authorization()
if skill_authorization is not None:
middleware_kwargs["skill_authorization"] = skill_authorization
if mcp_routing_middleware is not None:
middleware_kwargs["mcp_routing_middleware"] = mcp_routing_middleware
middlewares = build_subagent_runtime_middlewares(**middleware_kwargs)
@ -1205,9 +1221,67 @@ class SubagentExecutor:
# Filter by config.skills whitelist
if self.config.skills is not None:
allowed = set(self.config.skills)
else:
allowed = None
# Phase 3: enforce skill authorization (Layer 1). Filter the skill
# allowlist by the provider's "skill" policy so denied skills never
# reach the returned list (and are never turned into tools). Pass the
# already-loaded ``all_skills`` names as candidates so we don't pay a
# second ``load_skills`` round-trip inside the filter, and the memoized
# authorization context so the filter's provider instance is the same
# one the runtime ``skill:activate`` checks on this assembly use.
from deerflow.authz.skill_filter import filter_available_skills_by_authorization
resolved_app_config = self.app_config or get_app_config()
allowed = filter_available_skills_by_authorization(
allowed,
context=self._skill_authz_context(),
app_config=resolved_app_config,
# Same bucket convention as the middleware chain (self.user_id or
# DEFAULT_USER_ID); only consulted if candidates were not supplied.
user_id=self.user_id or DEFAULT_USER_ID,
candidate_skill_names=[s.name for s in all_skills],
authorization=self._resolve_skill_authorization(),
)
if allowed is not None:
return [s for s in all_skills if s.name in allowed]
return all_skills
def _resolve_skill_authorization(self):
"""The one skill-authorization context for this execution.
Resolved once per executor assembly (executors are built per task
dispatch) and shared by the Layer 1 filter in ``_load_skills``, the
runtime ``skill:activate`` check on the activation middleware, the
``describe_skill`` gate, and the skill-read stamping middleware — so
every layer of this assembly sees the same provider instance and
policy snapshot. Providers are uncached by design; resolving per layer
would let a custom provider observe different snapshots for visibility
and activation within one run.
"""
resolved = getattr(self, "_skill_authorization", None)
if resolved is None:
from deerflow.authz.skill_filter import resolve_skill_authorization
resolved = resolve_skill_authorization(self._skill_authz_context(), self._get_resolved_app_config())
self._skill_authorization = resolved
return resolved
def _skill_authz_context(self) -> dict[str, Any]:
"""Identity mapping shared by the Layer 1 filter and the runtime
``skill:activate`` check wired into the subagent middleware chain."""
return {
"user_id": self.user_id,
"user_role": self.user_role,
"oauth_provider": self.oauth_provider,
"oauth_id": self.oauth_id,
"channel_user_id": self.channel_user_id,
"is_internal": self.is_internal,
"authz_attributes": self.authz_attributes,
}
async def _build_initial_state(self, task: str) -> tuple[dict[str, Any], list[BaseTool], "DeferredToolSetup"]:
"""Build the initial state for agent execution.
@ -1241,6 +1315,7 @@ class SubagentExecutor:
skills,
enabled=resolved_app_config.skills.deferred_discovery,
container_base_path=resolved_app_config.skills.container_path,
skill_authorization=self._resolve_skill_authorization(),
)
# Apply authorization Layer 1: filter tools before deferred assembly

View File

@ -389,3 +389,86 @@ def test_lead_agent_filters_all_model_visible_tools_and_reuses_provider(monkeypa
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

View File

@ -1597,6 +1597,7 @@ class TestEnsureAgent:
patch.object(client, "_get_tools", return_value=[safe_tool, denied_tool]),
patch("deerflow.authz.tool_filter.resolve_authorization_provider", return_value=provider),
patch("deerflow.agents.lead_agent.agent.resolve_authorization_provider", return_value=provider),
patch("deerflow.authz.skill_filter.resolve_authorization_provider", return_value=provider),
patch("deerflow.runtime.checkpointer.get_checkpointer", return_value=None),
):
client._ensure_agent(client._get_runnable_config("t1"), context={"user_role": "user"})

View File

@ -682,10 +682,10 @@ def _stub_client_assembly(monkeypatch) -> dict[str, str]:
monkeypatch.setattr("deerflow.client.create_agent", lambda **kwargs: object())
monkeypatch.setattr("deerflow.client.build_middlewares", lambda *args, **kwargs: [])
monkeypatch.setattr("deerflow.client.DeerFlowClient._get_tools", staticmethod(lambda *, model_name, subagent_enabled, mcp_plugins=None: [])) # noqa: ARG005
monkeypatch.setattr("deerflow.client.get_enabled_skills_for_config", lambda app_config: []) # noqa: ARG005
monkeypatch.setattr("deerflow.client.get_enabled_skills_for_config", lambda app_config, **kw: []) # noqa: ARG005
monkeypatch.setattr(
"deerflow.client.build_skill_search_setup",
lambda skills, *, enabled, container_base_path: SimpleNamespace(describe_skill_tool=None, skill_names=frozenset()), # noqa: ARG005
lambda skills, *, enabled, container_base_path, skill_authorization=None: SimpleNamespace(describe_skill_tool=None, skill_names=frozenset()), # noqa: ARG005
)
monkeypatch.setattr(
"deerflow.client.assemble_deferred_tools",
@ -699,11 +699,16 @@ def _stub_client_assembly(monkeypatch) -> dict[str, str]:
monkeypatch.setattr("deerflow.client.get_effective_user_id", lambda: "user-123")
# ``apply_tool_authorization`` (called with the empty tool list above) still
# resolves a provider via ``tool_filter.resolve_authorization_provider``; route
# it at an allow-all RBAC provider so the empty list stays empty.
# it at an allow-all RBAC provider so the empty list stays empty. The skill
# filter (added in Phase 3 Skills PR) resolves via ``skill_filter`` namespace.
monkeypatch.setattr(
"deerflow.authz.tool_filter.resolve_authorization_provider",
lambda config: RbacAuthorizationProvider(roles={"user": {"tools": {"allow": "*"}}}),
)
monkeypatch.setattr(
"deerflow.authz.skill_filter.resolve_authorization_provider",
lambda config: RbacAuthorizationProvider(roles={"user": {"skills": {"allow": "*"}}}),
)
return captured

View File

@ -401,7 +401,7 @@ def test_slash_activation_and_policy_compose_on_the_same_model_call(monkeypatch)
editable=False,
)
activation_middleware = SkillActivationMiddleware(slash_source_owner_token=_SLASH_SOURCE_OWNER_TOKEN)
monkeypatch.setattr(activation_middleware, "_resolve_activation", lambda _: _ActivationResolution(activation=activation))
monkeypatch.setattr(activation_middleware, "_resolve_activation", lambda _, activation_decisions=None: _ActivationResolution(activation=activation))
policy_middleware = _middleware([skill])
request = ModelRequestStub(
[NamedTool("task"), NamedTool("read_file"), NamedTool("review_skill_package")],

File diff suppressed because it is too large Load Diff

View File

@ -339,8 +339,9 @@ Phase 1 最低验证要求:
- **兼容性:** `authorization.enabled: false` 时两条路径均为 no-op(路由返回全部模型,
解析返回原名)。匿名请求(user=None)不触发过滤。RBAC provider 的 `_RESOURCE_POLICY_KEYS`
已包含 `"model": "models"`,无需 schema 变更。
- **证据:** `tests/test_models_authorization.py`(24 tests)覆盖 disabled/anonymous/
RBAC allow/deny/wildcard/fail-closed/fail-open 路由场景,disabled/allowed/
- **证据:** `tests/test_models_authorization.py`(26 tests)覆盖 disabled/anonymous/
RBAC allow/deny/wildcard/fail-closed/fail-open 路由场景(含 provider-resolution-error
fail-closed/fail-open),disabled/allowed/
graceful-fallback/all-denied-fail-closed/all-denied-fail-open/custom-provider-list-vs-use/
no-usable-fallback 运行时场景,以及 `DeerFlowClient._ensure_agent` 的 model:use 强制 +
None 默认解析 + disabled no-op 集成场景;
@ -349,6 +350,60 @@ Phase 1 最低验证要求:
- **延期:** Skills、Sandbox 权限(Phase 3 后续 PR);前端 effective-permissions 展示;
management route 的 provider 迁移。
### 2026-07-28 — Phase 3 / Skills authorization (activate)
- **背景:** Phase 2A 合并后,技能仍然对所有已认证用户开放——`available_skills`
仅由 agent config 白名单控制,不检查角色。RFC §9 Phase 3 要求覆盖 Skills 资源类型。
技能的 enforcement 与 Models 不同:技能没有 Gateway route,而是在 harness 层
(lead agent assembly + subagent executor + slash-activation middleware)执行。
- **决策(Layer 1 — 装配阶段):** 新增 `filter_available_skills_by_authorization()`
过滤技能名称集合(`set[str] | None`),而非 `Skill` 对象列表。在 lead agent
`_make_lead_agent` 中,该函数在 `_available_skill_names()` 之后、`_load_enabled_available_skills()`
之前执行,使得 catalog(`describe_skill`)和 `SkillActivationMiddleware`(slash 激活)
共享同一个已过滤的 allowlist。在 subagent `executor.py` 的 `_load_skills()` 中同样过滤
`config.skills` 白名单。
- **决策(Layer 2 — 运行时 slash 激活,2026-09-03 修订):** 成员检查之外,显式
slash 激活还执行动作级 `authorize("skill", "activate")` 检查。`SkillActivationMiddleware`
新增 `skill_authorization` 参数(`ResolvedSkillAuthorization`:装配时解析一次的
provider + principal + fail_closed),在 `_resolve_activation` 的成员检查之后调用
`_activation_allowed()`;deny 返回与成员拒绝相同的用户文案(不泄露原因),provider
异常按 fail_closed 拒绝 / fail_open 放行。三条链路(lead `build_middlewares`、
`DeerFlowClient._ensure_agent`、subagent `build_subagent_runtime_middlewares`)均接线。
修订原因:Layer 1 的 `filter_resources` 是动作无关的可见性层,动作感知的自定义
provider 可以"可见但拒绝激活"——原设计(仅成员检查)对该类 provider 不闭合。
~~`describe_skill` 与 in-context 秘密绑定保持在可见性层~~(2026-09-04 再修订:
`describe_skill` 与 skill-file-load 路径也执行动作检查,见下方 2026-09-04 日志;
in-context 秘密绑定经由"拒绝的读取不进 `skill_context`"传递性覆盖)。
- **决策(候选集解析的 fail-closed):** 当 `available_skills=None`(无 agent 级白名单)
且 `_all_configured_skill_names` 抛错(storage I/O 错误)时,不静默返回 `None`
(会让所有技能绕过授权)。改为让 `_all_configured_skill_names` 抛异常,调用方按
`fail_closed` 决策:fail-closed → `set()`(拒绝全部),fail-open → `None`(无限制)。
与 provider 错误的 fail-closed/open 处理对称。
- **决策(通用过滤器):** 新增 `filter_resources_by_authorization()` 在 `enforcement.py`,
是 `filter_tools_by_authorization` 的泛化版本,适用于任何有 `name` 属性的资源(技能、模型等)。
- **否决方案(2026-09-03 部分反转):** 原方案否决在 `_resolve_activation` 中添加
运行时 `authorize("skill", "activate")` 检查,理由是需要将 provider + principal 传入
middleware 构造函数并改变 `build_middlewares` 签名,且认为 Layer 1 过滤已覆盖激活路径。
该理由对动作无关的 RBAC provider 成立,但对动作感知的自定义 provider 不成立(可见 ≠
可激活,评审 P1 发现并复现)——现按上文 Layer 2 修订落地,"穿 provider/principal 进
构造函数"的成本被接受,与 `_authorize_model_name` 的第二重 `authorize("model", "use")`
检查模式一致。仍然成立的部分:不为技能新增独立 Gateway route(技能管理路由保持
`require_admin_user`,per §12 Q6);~~`describe_skill` 不做动作检查(见 Layer 2 修订)~~
(2026-09-04 再修订:describe 与 skill-file-load 均已加动作检查,见下方日志)。
- **兼容性:** `authorization.enabled: false` 时 `filter_available_skills_by_authorization`
是 no-op(返回原始 allowlist)。`available_skills=None`(无 agent 级白名单)+ 启用授权时,
从 config 解析全部技能名并过滤。RBAC provider 的 `_RESOURCE_POLICY_KEYS` 已包含
`"skill": "skills"`,无需 schema 变更。对 test mock(SimpleNamespace app_config)安全:
使用 `getattr` + `is not True` 防御。
- **证据:** `tests/test_skills_authorization.py`(45 tests,2026-09-04 两轮扩充)覆盖 disabled/RBAC allow/deny/
wildcard/empty/provider-error-fail-closed-fail-open/internal-caller 场景,generic filter,
候选集解析错误的 fail-closed/fail-open + 空配置无 bypass 回归,以及 Layer 2:动作级
deny 阻断激活(lead 中间件单测 + client 装配接线 + subagent 链接线)、provider 异常
fail-closed/fail-open、候选集复用 catalog loader(不再二次扫描)、effective user
三处统一。既有 authz + skills 测试全部通过。
- **延期:** Sandbox 权限(Phase 3 后续 PR,已由 #4911 落地);前端 effective-permissions 展示;
management route 的 provider 迁移。Models 权限由并行 PR(#4540)处理。
### 2026-08-02 — Phase 3 / Sandbox authorization (execute)
- **背景:** Phase 3 Models 合并后,sandbox 仍只由 config presence
@ -438,6 +493,92 @@ Phase 1 最低验证要求:
event loop;loop-affine provider 在 `__init__` 调用 `asyncio.get_running_loop()` 并在
`aauthorize()` 验证仍是同一个 loop。
### 2026-09-04 — Phase 3 / PR #4541 自主加载路径动作门控、bootstrap 装配收口与 subagent provider 复用
- **背景:** review(CHANGES_REQUESTED)发现三个缺口:① 动作级 `skill:activate` 只在
显式 slash 路径执行——动作感知 provider 可以让技能"可见"(`filter_resources` 放行)
但拒绝激活,模型仍可 `describe_skill` → `read_file`,`DurableContextMiddleware` 把文件
记入 `skill_context` 后 allowed-tools 策略与自主秘密绑定全部生效,被拒的动作从未检查;
② bootstrap 分支把未过滤的 `set(_BOOTSTRAP_SKILL_NAMES)` 传给 `build_middlewares` /
`apply_prompt_template`,且 `skill_setup.skill_names or None` 在策略拒绝 `bootstrap` 时
把空集转回 `None`,legacy 全量 prompt 会重新加载并宣传被拒技能;③ subagent
`_load_skills()` 与 `_create_agent()` 各自解析一次 provider——provider 明确不缓存,
两层可能拿到不同实例/策略快照,违反"Layer 1/Layer 2 同实例"契约。
- **决策(共享动作检查):** `skill_filter.skill_activation_allowed(authorization, name)`
成为唯一的动作检查实现(provider + principal + fail_closed 语义,异常按配置
fail-closed/open)。slash 路径(`SkillActivationMiddleware._activation_allowed` 委托)、
`describe_skill`(匹配集过滤,全部被拒时与"无匹配"不可区分以避免泄露原因)、
skill-file-load 盖章路径三处共用,防止语义漂移。
- **决策(自主加载路径的 gate 位置):** 盖章生产者 `ToolErrorHandlingMiddleware
._stamp_skill_read_metadata` 是单一收口点——`skill_context` 条目、`SkillToolPolicyMiddleware`
的 allowed-tools、`_in_context_secret_sources` 的秘密绑定全部以该盖章为源头。动作被拒的
读取改盖 `SKILL_CONTEXT_DENIED_KEY` 拒绝标记(而非条目元数据),`extract_skills` 见标记
即跳过且不产生 "missing skill read metadata" 告警。`skill_authorization` 经
`_build_runtime_middlewares` → `build_lead_runtime_middlewares` /
`build_subagent_runtime_middlewares` 穿入。文件内容本身仍可读——可见性是 Layer 1 的
决定(与 sandbox projection 同语义),被拒的是"激活"(策略/秘密/持久上下文)。
- **决策(bootstrap 装配):** bootstrap 分支全程使用过滤后的 `available_skills`
(拒绝时为空集:激活中间件成员门 = 不可激活任何技能;legacy prompt 路径的空 allowlist
分支返回空);`skill_names` 仅在 deferred discovery 开启时保留(含空集),关闭时保持
`None`(legacy 渲染)——允许路径行为不变。
- **决策(subagent provider 复用):** `SubagentExecutor._resolve_skill_authorization()`
按执行器实例记忆化(executor 每次任务派发新建),Layer 1 filter(`authorization=`)、
激活中间件、describe 盖章、read 盖章四点共用同一 `ResolvedSkillAuthorization`。
- **否决方案:** 不在 `read_file` 工具本体 gate(需要路径→技能名映射且影响非技能读取);
不在 `DurableContextMiddleware` gate(时序上晚于 `SkillToolPolicyMiddleware` 读 state,
依赖 hook 顺序);不在装配期过滤 catalog(会把 `<skill_index>` 可见性折叠进激活语义,
且需 N 次 authorize 调用;运行期过滤每次 ≤ MAX_RESULTS 次)。
- **证据:** `tests/test_skills_authorization.py` 45 tests(describe 动作门控 + provider
错误 fail-closed/open + 禁用时全量;read 盖章拒绝/放行 + `extract_skills` 无告警跳过;
lead/subagent 链 stamping 接线;distinct-instance 工厂回归——`_load_skills` 与
`_create_agent` 共享一个实例且仅解析一次)+ `test_authorization_enforcement.py`
bootstrap 三参数回归(允许/拒绝×deferred 开关)。突变验证:逐项还原六处修复,
对应测试均失败。
- **兼容性:** `authorization.enabled: false` 时三处 gate 均 no-op;`build_skill_search_setup` /
`build_describe_skill_tool` / `ToolErrorHandlingMiddleware` / 两个 runtime builder 的新
参数均默认 `None`;bootstrap 允许路径与 deferred 关闭路径行为不变。
### 2026-09-04(第二轮)— PR #4541 持久化条目复授权与异步 provider API
- **背景:** review 第五轮发现两个缺口:① `skill_context` 跨 run 持久——条目在盖章时
授权过,但下一轮被 `SkillToolPolicyMiddleware`(allowed-tools)与
`_in_context_secret_sources()`(秘密绑定)直接消费,不再查 `skill:activate`;provider
从允许翻转为拒绝后,旧条目仍在施加策略与绑定秘密。② `skill_activation_allowed()`
只调同步 `provider.authorize()`——异步执行路径因此用错 API:盖章在 event loop 上
同步调用、slash 激活与 describe 在 worker 线程同步调用;loop-affine provider 永远
收不到 `aauthorize()`,同步调用可能阻塞或失败,把允许错成 fail-closed 拒绝、把拒绝
错成 fail-open 放行。
- **决策(异步助手):** `skill_filter.skill_activation_allowed_async()` 与同步版同语义
(同 fail-closed/open),经 `await provider.aauthorize()`,镜像
`authorize_sandbox_execution_async` 的既有模式。
- **决策(决策预计算模式):** 异步 hook 在 event loop 上预计算
`activation_decisions: dict[name, bool]`(slash 目标名 + 持久化条目名,一次批量
aauthorize),传入 to_thread 中的阻塞处理函数;处理函数查映射,未覆盖的名字回退
同步检查(同步路径的正确 API)。三处落地:`SkillActivationMiddleware.awrap_model_call`
(决策贯穿激活与秘密绑定)、`ToolErrorHandlingMiddleware.awrap_tool_call`(`_amaybe_stamp`
先 await 决策再盖章)、`SkillToolPolicyMiddleware.awrap_model_call` / `awrap_tool_call`
(条目名 + slash 来源名预计算)。
- **决策(describe 双实现):** `describe_skill` 用 `StructuredTool.from_function`
同时挂同步函数与 coroutine——`invoke`(测试/同步链)走 `authorize()`,
`ainvoke`(异步 agent 执行)在 loop 上 `await aauthorize()`。
- **决策(持久化条目复授权):** 两个消费点在使用前复授权:
`_in_context_secret_sources` 对每个解析后的条目查动作决策,拒绝则跳过(不绑定秘密);
`SkillToolPolicyMiddleware._active_skills_for_paths` 对每个解析后的技能查动作决策,
拒绝则 `continue`(与 disabled / 出 allowlist 的技能同等跳过;全部被拒时沿用既有
"no active reference authorized → fail-closed builtins" 语义)。`skill_authorization`
新传入 `SkillToolPolicyMiddleware`(lead `build_middlewares` 与 subagent builder 两处)。
- **否决方案:** 不在 `DurableContextMiddleware` 渲染时复授权(消费点直接读 state,
渲染层过滤不覆盖 policy/secret 读取);不在条目上缓存决策(持久化条目的正确缓存
粒度是"每次消费时重查",与 live registry 复验同一模式)。
- **证据:** `tests/test_skills_authorization.py` 45 tests:allow→deny-next-run 双回归
(秘密绑定断源、allowed-tools 部分拒绝不株连、全拒 fail-closed builtins)、
`_AsyncOnlyProvider`(同步 API 恒失败、aauthorize 可用)驱动四条异步路径断言
(激活、盖章允许/拒绝、describe、tool policy——允许场景下正确接线保留技能声明,
错回退同步 API 则 fail-closed 丢声明)。六项突变(两个复授权 gate + 四处异步预计算)
逐一还原均有测试失败。
- **兼容性:** 同步 hook 路径行为不变(决策映射仅异步构造);`SkillToolPolicyMiddleware`
新参数默认 `None`;`describe_skill` 对 `invoke` 调用方(既有测试)保持同步语义。
### 2026-09-17 — Phase 4 / PR #5489 Skills listing visibility (list / detail)
- **背景:** Phase 4 PR 1(#5228 `/me` route permissions)与 PR 2(#5294 前端权限门控)
@ -698,6 +839,317 @@ Phase 1 最低验证要求:
checkpoints 文档里的 `appcfg:531-575` / `appcfg:570-575` 行号引用在主线自己新增 prompt overlay
后已失准(当前 `_check_config_version` 位于 `appcfg:534-576`),不属本 PR 面内。
### 2026-09-25 — PR #4541 规范技能名:授权目标统一为注册表 `Skill.name`
- **背景:** review(ShenAC-SAC,P2)发现路径推导名与声明名不一致:bundled 技能目录名
可不同于 SKILL.md 声明的 `name`(`skills/public/vercel-deploy-claimable` 声明
`vercel-deploy`),而 Layer 1、slash 激活、`describe_skill` 授权的都是声明名。
① read 盖章路径(同步 `_stamp_skill_read_metadata` / 异步 `_amaybe_stamp`)用
`_skill_name_from_path()`(目录 basename)做 `skill:activate` 目标——RBAC
`allow: ["vercel-deploy"]` 下读取被误盖 `skill_context_denied`;② 异步决策预计算 map
的键同样用路径推导名(slash 来源 + 持久化 `entry["name"]`,后者本身由
`extract_skills` 按 path 盖章、必然是路径名),而消费点 `_active_skills_for_paths` /
`_in_context_secret_sources` 查的是注册表解析出的 `skill.name`——map miss 回退
worker 线程里的同步 `authorize()`(`_AsyncOnlyProvider` 下直接 fail-closed 丢工具,
且破坏上一轮消除同步回落的成果)。
- **决策(共享注册表解析):** 新增 `deerflow/skills/container_registry.py`:
`build_container_path_registry(storage)`(规范化容器 SKILL.md 路径 → live `Skill`,
`enabled_only=False`)与 `canonical_skill_name(registry, path)`。三个中间件
(activation / tool-policy / tool-error-handling)统一经它把路径解析为声明名。
- **决策(盖章路径):** `ToolErrorHandlingMiddleware` 新增 `user_id`(经
`_build_runtime_middlewares` → lead `build_middlewares` / subagent builder 穿入,
与另两个技能中间件同一 storage 解析顺序——user-scoped 优先)。同步路径在
`_canonical_skill_name()` 中解析(registry 失败或路径未解析 → 回退路径名,未解析路径
的下游消费本就按同一 registry 跳过);异步路径 `asyncio.to_thread` 解析后于 loop 上
`await skill_activation_allowed_async(...)`。
- **决策(决策 map 键):** `SkillToolPolicyMiddleware` 异步 hook 改三段式:
to_thread 加载 registry 并把策略路径规范名化(`_resolve_policy_registry`)→ loop 上
`aauthorize()` 批量预计算(`_collect_activation_decisions(names)` 只收规范名)→
to_thread 过滤并复用同一 registry(整 hook 一次 skill-tree 扫描)。slash 来源不再
basename 推导,持久化条目一律取 `entry["path"]` 解析(不信任 `entry["name"]`),
`_entry_names` 删除。`SkillActivationMiddleware` 同理:候选收集拆为
`_candidate_activation_targets()`(slash 名——本就是注册表名 + 条目路径,loop-safe),
`awrap_model_call` 先 to_thread 规范名化(`_canonical_names_for_paths`)再预计算。
- **否决方案:** 不在 `extract_skills` 盖章时把 `entry["name"]` 改写为声明名(渲染层
展示名与路径一致有其意义,且历史条目仍需注册表解析,改写只修新条目不修存量);
不在 map 里同时预计算"路径名 + 声明名"两个键(掩盖键错配而非消除,同步回落仍可能
命中错误键)。
- **证据:** 新增 5 个回归(`tests/test_skills_authorization.py` 45→50):同步盖章断言
provider 目标为声明名(`sync_calls == ["vercel-deploy"]`);RBAC allow 声明名 → 激活 /
allow 目录名 → 拒绝(双向钉死经注册表解析);异步盖章 `_AsyncOnlyProvider` 下
`async_calls == ["vercel-deploy"]` 且条目盖章(同步回退会 fail-closed 出拒绝标记);
组合路径(async slash 激活 → tool-policy)`async_calls` 恰为声明名×2、`bash` 保留
(回退同步 API 则 fail-closed 丢 `bash`);持久化条目(路径名盖章的历史形态)经
注册表规范名化后秘密绑定仍生效(路径名键 → map miss → 同步回退 → 绑定丢失)。
三处突变(盖章路径名、tool-policy map 键、activation 条目名)逐一还原均有测试失败。
- **兼容性:** 授权禁用时零新增开销(`skill_authorization is None` 不触达 registry);
`ToolErrorHandlingMiddleware.user_id` 与两个 builder 的新参数默认 `None`;
未解析路径回退路径名(与旧行为一致);同步 hook 路径语义不变。
### 2026-09-26 — PR #4541 review(willem-bd,P2×2):预扫描失败传递与空路径零扫描
- **背景:** 合入主线后的新一轮 review 指出规范名修复引入的两个缺口:①
`_resolve_policy_registry` 瞬时失败返回 `(None, [])`,worker 侧
`registry is None` 会重试存储——重试若成功,恢复出的技能名不在(空的)决策 map
里 → 回退 worker 线程的同步 `authorize()`:loop-affine provider 抛异常后
`fail_closed=false` 变成放行(被拒技能的 allowed-tools 保留),
`fail_closed=true` 变成误拒;② 授权开启时每个异步模型步都调用
`_canonical_names_for_paths`,空 `entry_paths` 也触发全量技能树扫描——普通请求
(无 slash、无条目、无 secrets)此前零注册表 I/O,大目录/NFS 下每步 LLM 前多一次
未缓存扫描。
- **决策(失败传递):** `_REGISTRY_LOAD_FAILED = object()` 三态哨兵(对齐同文件
`_MISSING_POLICY_DECISION` 先例):`_resolve_policy_registry` 失败时返回哨兵,
`_active_skills_for_paths` 见哨兵直接 `([], True)`(与首次加载失败同一 fail-closed
builtins 处置),不再静默重试。失败步的 builtins 决策经 refresh_decision 缓存
仅作用于本步;下一步模型调用预扫描重试存储即可恢复,无粘性状态。哨兵分支先于
"No active skill references could be authorized" 告警返回——存储失败不产生该误导性
日志,异常日志(`_resolve_policy_registry` 内)是唯一信号。
- **决策(空路径零扫描):** `_canonical_names_for_paths` 开头 `if not paths:
return []`(单一 choke point,未来调用方自动受益)。普通异步模型步的存储调用数
归零(slash 解析在触达 storage 前返回 None;secrets 不存在则
`_resolve_secret_bindings` 不加载注册表)。
- **否决方案:** 不在失败后立即重试恢复(reviewer 给的第二选项)——多一次全量扫描
且复杂度更高,保留失败与既有"首次加载失败"语义逐字一致;不在调用点条件跳过
`to_thread`(choke point 单点守卫才可被单点突变钉住)。
- **证据:** `tests/test_skills_authorization.py` 50→52:
`test_async_policy_preserves_prepass_registry_failure`(首读失败、次读会成功的
flaky storage + `_AsyncOnlyProvider(denied)` + `fail_closed=False`:恰一次
storage 调用、零 provider 调用、builtins-only——旧行为下 worker 重试成功 →
同步回退 → fail-open 保留 `bash`);`test_async_model_call_without_skill_refs_
skips_registry_scan`(storage 调用计数 == 0)。两项突变(哨兵还原为 None、
删除空路径守卫)逐一还原均有测试失败。
- **兼容性:** 同步 hook(`registry is None` 自行加载)语义不变;`awrap_tool_call`
缓存命中路径不触达哨兵;授权禁用时两条新路径均不存在。
### 2026-09-26(第二轮)— 第 4 实例收口:activation 秘密绑定改快照传递,类成员 grep 封闭
- **背景:** 上一节修复落地后的严格自查复现出同一 bug 类的第 4 个实例:
`SkillActivationMiddleware.awrap_model_call` 的条目预扫描瞬时失败返回 `[]` 并继续,
线程内 `_resolve_secret_bindings` 在有 request secrets 时经
`_load_skill_registry_by_path` **另一次独立加载**恢复——条目解析出的名字不在(空)
决策 map → 回退 worker 线程同步 `authorize()` → `fail_closed=false` 时
aauthorize 拒绝的技能秘密被 fail-open 绑定(复现:两次加载、零 aauthorize、
被拒秘密已注入)。slash 路径经复现实证免疫(名字来自消息解析,不经存储,
预扫描失败也在 map 内)。
- **决策(快照传递,对齐 policy 侧哨兵修复的形状):**
`_canonical_names_for_paths` 返回 `(names, registry)`——名字与其来源快照一起返回;
快照经 `_handle_model_request` 穿到 `_resolve_secret_bindings`,条目来源解析**复用该
快照**:决策 map 的键 = 快照解析的名字,map miss 在构造上不可能(连"同一步两次
加载不一致"的理论窗口——预扫描成功后存储被并发改名——也一并关闭)。加载失败返回
`_REGISTRY_LOAD_FAILED` 哨兵:条目绑定归零(空注册表解析不出任何路径,构造保证),
**slash 来源不受株连**(它不查决策 map,在激活时已验证,属 run 级用户承诺,照常
走新鲜加载)。同步链不传快照、行为不变。附带收益:有 secrets 的异步步从两次
注册表扫描降为一次(快照复用),`_load_skill_registry_by_path` 的新鲜度契约
("下一次模型调用即吊销")仍然满足——快照取自本步开头。
- **决策(类成员封闭 + 延期加固锚点):** 全仓 grep 实证
`skill_activation_allowed(` 同步调用点恰 4 处:stamping 同步路径(合法)、
describe 同步实现(合法)、两个 `_activation_allowed`(activation / policy 中间件)
——**异步可达的同步回退点有且仅有 2 个,本轮修复后全部关闭**。结构性加固
(决策 map 类型化为 async-batch 语义对象,"异步路径 map miss = fail-closed + 响亮
日志,绝不静默同步回退"由构造保证;per-step 注册表快照经 run context 全链共享)
作为后续工作延期——四实例证明调用方自觉已失效四次,但结构性重构会再开 N 轮
review,先以实例收口 + 成员封闭 + 本锚点记录推进合并。
- **否决方案:** 不用"失败标志只关门条目"(上一轮初步规格)——标志只堵失败扇窗,
堵不住"预扫描成功但两次加载不一致"的窗口;快照传递同成本下把两类窗口都关成
构造不可能。不为空 `entry_paths` 跳过 `to_thread`(微秒级线程跳 vs 单点守卫的
突变可钉性,取后者;P3 有意放弃并记录)。
- **证据:** `tests/test_skills_authorization.py` 52→54:
`test_async_secret_binding_preserves_prepass_failure`(两技能两调用:调用 2 预扫描
第 3 次加载失败——条目绑定归零且 slash 绑定存活;旧行为同步回退 fail-open 绑定
被拒的 ENTRY_KEY)、`test_async_secret_binding_resolves_entries_against_prepass_snapshot`
(第二次加载返回改名后的同路径技能:条目必须按快照名(allow)而非新鲜名(deny)
解析)。三突变逐一还原必红:M1 哨兵改新鲜恢复(prepass failure 测试红)、M2 哨兵
株连 slash(同测试红)、M3 条目绕过快照(snapshot 测试红)。
- **兼容性:** 授权禁用或无条目路径时 `awrap_model_call` 传 `None`,
`_resolve_secret_bindings` 走历史新鲜加载,行为不变;同步链完全不变;
policy 中间件注册表参数以 `_RegistryArg` 别名收编退化联合类型。
### 2026-09-27 — PR #4541 review(willem-bd R9):slash 绑定参照点纠正与来源独立性
- **背景:** R8 的快照设计把 slash 来源也统一到了预扫描快照上——快照在
`aauthorize()` await **之前**拍摄,slash 激活在其后重新加载当前技能;窗口内
技能声明秘密 OLD_KEY→NEW_KEY 变化时,激活展示 NEW 内容而绑定注入 OLD(刚激活的
技能拿不到它声明的凭据、反而拿到已不声明的)。R8 的"一步一载"附送优化在此翻车。
- **决策(两个来源、两个参照点):** slash 来源永远走**激活后的新鲜加载**
(`_load_skill_registry_by_path()`)——绑定必须与激活刚读到的内容一致;slash
来源不查决策 map,快照一致性对它无价值。条目来源保留快照(map 键 = 快照名,
miss 构造不可能);哨兵失败条目归零;`None`(同步链)回退同一新鲜注册表(同步
authorize 在同步链是正确 API,无分叉)。两来源解析**相互独立**——新鲜加载瞬时
失败只归零 slash,条目继续按自己的快照绑定(审查自查发现初版修复把条目嵌在
slash 守卫下,该场景会误杀条目绑定,已独立成行并由回归钉住)。
- **复盘(为什么多轮自查仍漏):** 五轮 review 中 reviewer 找的几乎全是"上一轮
修复新创造的面";R9 的根源是快照统一这个**未被点名的附送优化**逃过了新面计价,
以及"新鲜度"论证用了错误参照点(对照文档契约"下一步调用"而非"同调用内的激活
读")且被写进文档后视为已封闭。已入 review-lessons 清单第 36–38 条(读取对×
变化窗口×参照点;附送优化单独计价;已记录论证可再攻击)。
- **四轴构造审计(举一反四,模拟 reviewer 方法)**:① 读取对×窗口——
prepass↔activation 有意分离(各有参照点,代码注释记录);activation↔binding
为两次新鲜加载、窗口为文件读+哈希(无 await,µs 级),reviewer 措辞
"fresh/activation-era metadata" 明示 fresh 可接受,且该窗口 PR 之前即存在,
完全封闭需将 Skill 对象穿透 `_Activation`,记录为已知窗口不扩面;跨中间件
双快照(activation 与 policy 各自预扫描)无共享决策消费,良性。② 返回值×
消费者——`(names, dict|sentinel|None)` 三值 × 唯一调用链全部处理,空 dict
快照构造性绑定归零。③ I/O 失败文法——异步有密步加载 {#1 prepass, #2 激活,
#3 slash 新鲜}×失败:#1 失败钉(既有)、#3 失败钉(本轮新增)、#2 失败
`_resolve_activation` 未捕获存储异常直接打断 run——**PR 之前既有**、不在本
diff,记录不搭车。④ 边界——快照 dict 跨线程只读共享,无变异点。
- **证据:** `tests/test_skills_authorization.py` 54→56:
`test_slash_secret_binding_uses_post_activation_registry`(预扫描成功+窗口内
slash 声明 OLD→NEW:注入 NEW、不注入 OLD);`test_snapshot_entries_bind_while_
slash_fresh_load_fails`(新鲜加载瞬时失败:slash 归零、条目仍按快照绑定)。
突变 M4(slash 回退快照)、M5(条目重嵌 slash 守卫)逐一还原必红;上一轮双调用
测试加载计数按新序列更新(#5)。
- **兼容性:** 同步链与授权禁用路径不变;"一步一载"附送收益放弃(回到 PR 前
加载计数),一致性优先;无新增 rider。
### 2026-09-27(第二轮自查)— 同技能双来源的"两个时代并集"与 slash 支配规则
- **背景:** 修复 R9 后按 reviewer 的构造审计法再读最终代码,构造出下一个场景并实证:
同一技能既是 slash 来源(新鲜注册表,v2/NEW_KEY)又有持久条目(预扫描快照,
v1/OLD_KEY)时,两来源并集同时注入两个时代的钥匙(复现输出
`{OLD_KEY, NEW_KEY}`)——OLD_KEY 正是"技能已不再声明却仍被注入"的形态,
与 R9 同型、经条目路径到达。工具策略中间件已有先例语言"Explicit slash
activation dominates for the rest of that run",秘密绑定对同一技能未对齐。
- **决策(对齐先例):** `_in_context_secret_sources` 新增 `exclude_names`——
已由 slash 来源绑定(激活时代)的技能名不再贡献条目视图。slash 是显式仪式且
解析自更新的注册表,同名条目视图只能使其过期或加宽;不同技能的条目照常
叠加(秘密是加法语义,与工具策略的排他语义不同——该不对称是既有设计,不动)。
- **证据:** `test_slash_era_dominates_entry_source_for_same_skill`(快照 v1 +
激活/新鲜 v2:仅注入 NEW_KEY);突变 M6(移除排除)必红(并集
`{OLD_KEY, NEW_KEY}` 回归)。套件 56→57。
- **兼容性:** 仅同名支配;异名 slash+条目叠加行为不变(既有双调用回归覆盖)。
### 2026-09-27(双角色审查轮)— 同步链授权分支的覆盖缺口
- **背景:** 双角色(找问题/解决问题)审查第 1 轮:异步快照路径有 8 个回归,但
**同步链 + 授权开启**(`wrap_model_call` + `skill_authorization` + 持久条目 +
secrets,走 `entry_registry=None` 新鲜回退分支)零覆盖——重构该分支的回归不会
红任何测试。行为先实证正确(规范名经同步 `authorize()`、绑定成功、恰一次
加载——同步 API 在同步链是正确契约)。
- **决策:** 补 `test_sync_chain_secret_binding_uses_sync_api_and_canonical_names`
(`sync_calls == ["vercel-deploy"]`、`async_calls == []`、绑定生效、loads == 1);
突变 M7(`entry_registry=None` 回退改空 dict)必红。
- **第 2 轮(换角度重扫)**:声明↔代码对齐逐条核(slash 不查 map、条目 map miss
构造不可能、支配排除先于激活检查);`tool_search` 确认在
`ALWAYS_AVAILABLE_BUILTIN_TOOL_NAMES` 中(skill 策略层不破坏 deferral,既有
设计已覆盖);秘密日志仅记录名字。无新发现,枚举空间记为已穷尽。
### 2026-09-27(第三轮自查,willem-bd 视角扫未审面)— user_id 接线回归缺口
- **背景:** 换到他从未审查的面:`container_registry.py` 新模块本身、`user_id`
穿线的接线回归、Command 路径细节。代码接线正确(`_build_runtime_middlewares`
把 `user_id` 传给 `ToolErrorHandlingMiddleware`,L514),但唯一接线测试
(`test_lead_runtime_chain_forwards_skill_authorization_to_stamp_gate`)只断言
`_skill_authorization`——**删掉 user_id 传递不会红任何测试**:stamp 门会静默
解析进程全局注册表,per-user 自定义技能的读路径回退路径推导名 → 错误授权
目标。subagent builder 同型缺口。
- **决策:** lead 接线测试补 `user_id="user-123"` 断言;新增
`test_subagent_runtime_chain_forwards_user_id_to_stamp_gate`。突变 M8(构造
调用去 user_id)双测试必红。
- **其他面:** `container_registry.py` 全文精读(40 行)——键规范化双向、
thread 纪律入档、`enabled_only=False` 理由明确,无发现。Command 路径 N-scan
与 activation↔binding µs 窗口维持既有记录。
- **证据:** 套件 58→59;ruff 干净。
### 2026-09-27(第四轮自查,willem-bd 视角扫未审面)— 持久条目的模型可见渲染不复授权
- **背景:** 未审面清单再推进:`DurableContextMiddleware` 渲染的 "Active skills"
提醒直接来自 `state["skill_context"]`,**无任何 `skill:activate` 复授权**(grep
实证)。provider 翻转为 deny 后:工具与秘密的消费点正确跳过(R6/R7 修复),
`describe_skill` 也过滤被拒技能(R5 修复)——但模型可见的持久提醒仍宣传该技能的
名字与路径,直到条目离开 `skill_context`。
- **定性:** 执行面无洞(模型重读 → stamp 门拒 → 不建新条目;声明工具/秘密均被
policy/secret 门拦下)——这是**声明精度问题 + 表面不一致**:早期记录中"被拒的是
激活(策略/秘密/持久上下文)"的"持久上下文"一词过度声明,精确表述应为"被拒读取
不产生新持久条目;既有条目的**消费**(工具/秘密)复授权,但其**模型可见渲染**
不过滤"。`DurableContextMiddleware` 属上游模块,渲染过滤记为 follow-up,不在本
PR 扩面(对齐清单第 35 条)。
- **证据:** grep 复授权关键词在该文件为空;渲染调用链
`render_skill_context(state.skill_context)` 无条件透传。
- **同轮其他未审面:** `release_policy_parameters` 契约(assembly_descriptor 的
可选鸭子类型;激活中间件发布的 `available_skills` 已是授权过滤后集合)——无发现。
### 2026-09-27(第五轮)— 渲染过滤落地:持久条目决策发布与 durable 消费
- **背景:** 第四轮将渲染过滤记为 follow-up 后,用户指令授权扩面实现。
- **决策(发布/消费分离):** `SkillActivationMiddleware` 每步发布**路径键**的
``skill:activate`` 决策到 run context(`__skill_entry_activation_decisions`,
复用 slash source 的 owner-token 认证契约;已加入 `REDACTED_CONTEXT_KEYS`):
异步 hook 复用预扫描快照(零新增 I/O,失败/不可解析发布 False=隐藏);同步
hook 仅在有条件目时一次扫描 + 同步 authorize(被动步零 I/O,对齐 P2-B 先例)。
`DurableContextMiddleware` 纯消费——按发布决策过滤**渲染副本**(state 不动),
自身不做任何 provider/storage 工作;未发布(授权禁用/无条目/非法载体)= 照旧
渲染(absent-is-permissive,与其他 run-context 载体一致)。lead 与 subagent
两链均接线 `skill_authorization` + 共享 token。
- **证据:** 3 个新测试(异步组合断言过滤生效且 `load_calls == 1`(渲染复用发布、
不自扫);同步组合同断言;授权禁用渲染不变)。突变 M9(消费端不过滤)、
M10(异步不发布)、M11(同步不发布)逐一必红。同步链既有测试的 sync_calls /
load 计数按新的合法双调用更新。
- **兼容性:** 授权禁用路径零变化;`release_policy_parameters` 新增
`skill_entry_render_filter` 布尔;state 不被渲染过滤触碰。
### 2026-09-27(第六轮自查)— 渲染过滤的链级接线回归
- **背景:** 渲染过滤落地后的新面上再执行"他式"检查:链级 wiring(token 配对、
`skill_authorization` 传递、activation 先于 durable 的构造顺序——发布必须先于
消费)无任何测试;任一被静默删除,过滤失效且无测试变红(与 M8 同类)。
- **决策:** 两个链级接线测试(lead `build_middlewares` / subagent
`build_subagent_runtime_middlewares`):断言 durable 拿到同一 resolved 实例、
token 与链上 activation 中间件相等、`index(activation) < index(durable)`。
突变 M12(lead 去 token)/ M13(subagent 去 authz)各自必红。
- **证据:** 套件 62→64;受影响面 302 passed;ruff 干净。
### 2026-09-27(第二次 38 条全清单重跑)— 新面增验
- **重跑范围:** 渲染过滤落地后的 7 文件改动面。新增取证:新符号出现点枚举
(`_publish_entry_decisions` 双调用点=同步/异步 hook;`_renderable_skills`
单消费;write/read 单写单读;key 三用途=写/读/redaction 清单);上游漂移
**累计 21 个新提交**(`827acf51d..52a3e2564`)——提交前合并义务加重。
- **发现并补齐 [第 5/24 条]:** 新载体 key 无 redaction 断言——补
`test_entry_decisions_carrier_is_redaction_listed`(断言剥离 + "redacted,
not suppressed":源载体保持完整)。
- **维持:** M9–M13 突变全红记录、发布/消费构造保证、接线/顺序双测试、
fail_closed=True(生产默认)方向覆盖。套件 64→65。
### 2026-09-28 — PR #4541 review R10(willem-bd,P2):无秘密时代的支配排除
- **背景:** R9 修复推送后的新发现:同技能排除集此前从"成功绑定的 slash 来源"
推导(`slash_bound = sources 名`)。当预扫描快照为旧声明(OLD_KEY)、operator
在 await 窗口内把技能改为**不声明任何秘密**时:激活与新鲜 slash 查询看到新版,
`_resolve_registry_skill` 因 `required_secrets` 为空返回 None → slash 来源不进
sources → 排除集为空 → 旧快照条目视图照旧把 OLD_KEY 注入——刚激活的技能已
不声明任何秘密却仍被注入。
- **决策(身份推导):** 排除集改为从**已认证的 slash 激活身份**(run context 里的
slash source path,token 认证)推导:身份路径先在新鲜注册表、后在条目快照中
解析出名字(名字不因声明变化而失锚),加入排除集——与该技能当前是否绑定成功
无关。slash 身份在两处注册表都不可解析(技能已卸载)时不排除——沿用条目的
快照参照点语义。
- **证据:** `test_slash_dominance_holds_when_activation_era_declares_no_secrets`
(快照 OLD、激活/新鲜=无秘密:`ACTIVE_SECRETS_CONTEXT_KEY is None`);突变 M14
(身份推导禁用,退回 sources 推导)必红。套件 65→66。
- **兼容性:** 有秘密时代的支配行为不变(既有 dominance 回归覆盖);无 slash
身份时排除集来源不变。
### 2026-09-28(R10 修复自查)— 改名交错:身份锚定从名字升级为路径
- **背景:** 对 R10 修复本身执行清单第 39–41 条(用户点名"当前修改也要做设计/
方向处理")。第 40 条(值→门→下游)命中:只枚举了 `required_secrets` 的 ∅,
没枚举**声明 `name` 本身的变化**。复现坐实:operator 在 await 窗口内改名
(同路径 foo→bar),用户按新名 `/bar` 激活——名字锚定的排除集只含 "bar",
快照条目把同一路径解析为旧名 "foo" → 不被排除 → `{OLD_KEY, NEW_KEY}` 两
时代并集,R10 的洞经改名存活。根源:条目与 slash 来源共享的稳定身份是
**路径**;名字是每注册表版本可变的派生属性。
- **决策(路径锚定 + 名字补充):** `_in_context_secret_sources` 新增
`exclude_paths`——条目规范化路径 == 已认证 slash 身份路径即排除(先于注册表
解析,不依赖任一版本叫它什么);名字排除保留,覆盖同名异径遮蔽(custom
shadow public)。身份路径不可解析(已卸载)时路径排除仍然生效——身份是
run 级承诺。
- **证据:** `test_slash_dominance_anchors_on_path_across_midrun_rename`
(快照 foo/OLD_KEY,激活/新鲜 bar/NEW_KEY,按新名激活:仅 NEW 绑定);
突变 M16(仅路径排除禁用)必红。套件 66→67;受影响面 242 passed。
- **复盘:** 本洞由"对刚写的修复立即执行新清单条目"抓出——第 41 条
(重攻自身修复)的直接收益。
### 新记录模板
```markdown