feat(authz): construction-enforced async decision batches (ActivationDecisions) (#6508)

* feat(authz): construction-enforced async decision batches (ActivationDecisions)

The follow-up promised in the #4541 review threads. Four review rounds
traced four instances of one failure family: an async execution path
whose decision-map miss silently fell back to the synchronous
authorize() from a worker thread — the wrong API for loop-affine
providers, and under fail_closed:false a provider error there flips a
denial into an allow. The carrier was a plain dict|None held together by
caller discipline.

ActivationDecisions (deerflow/authz/activation_decisions.py) makes the
contract construction-enforced:
- a covered name returns the batched decision (unchanged semantics);
- a MISS resolves per the configured provider-error policy (fail-closed
  denies, fail-open allows) with a loud WARNING — a miss is a
  prepass/consumer divergence, a construction bug, not a policy
  question — and never touches a provider;
- None remains the sync-chain signal (synchronous authorize() is correct
  there);
- an empty candidate set still yields an empty BATCH, not None, so an
  unexpected consumer name fails per policy instead of silently taking
  the sync path;
- get_or_none lets publishers distinguish 'uncovered' from False;
- fail_closed travels with the batch from the resolved authorization
  context.

Both middlewares (_activation_allowed in SkillActivationMiddleware and
SkillToolPolicyMiddleware) migrate to the type; their collectors return
batches; the render-decision publisher adapts. Tests 65->70+5: type unit
tests (covered/miss both directions with loud-log assertions/
get_or_none/container), middleware-level miss semantics (fail-open
allows with provider.sync_calls == [], production-default fail-closed
denies, None sync chain still synchronous), and mutation M18 (restoring
the miss's sync fallback) verified red. 252 tests green on the affected
surface.

* docs(authz): sync every decision-batch contract description to the new miss semantics

Review round 1 on #6508 — all documentation-level: the docstrings still
described the miss-as-synchronous-fallback contract this PR removes.

- _activation_allowed (both middlewares): a covered name returns the
  batched decision; a miss resolves per the carried provider-error policy
  with a WARNING and never touches a provider; None (sync chain /
  disabled) is the only synchronous case.
- _collect_activation_decisions / _resolve_policy_registry /
  _active_skills_for_paths: the reason not to diverge is now a
  policy-resolved miss (denied under the production fail-closed default;
  allowed without any provider consult under fail-open; logged loudly),
  not a silent sync fallback.
- The two _REGISTRY_LOAD_FAILED sentinel comments, same rewording.
- Drop unused tmp_path/monkeypatch fixtures from the two new miss tests.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
hataa 2026-10-09 16:09:17 +08:00 committed by GitHub
parent 845af59301
commit 3e5bd081db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 304 additions and 48 deletions

View File

@ -20,6 +20,7 @@ from langchain.agents.middleware.types import ModelRequest, ModelResponse
from langchain_core.messages import AIMessage, HumanMessage
from deerflow.agents.middlewares.skill_usage import SKILL_USAGE_KEY, SKILL_USAGES_KEY, build_skill_usage, record_skill_usage
from deerflow.authz.activation_decisions import ActivationDecisions
from deerflow.runtime.events.catalog import (
MIDDLEWARE_SKILL_ACTIVATION_TAG,
MIDDLEWARE_SKILL_SECRETS_TAG,
@ -49,9 +50,9 @@ _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.
# entry names resolved from a recovered registry would surface as batch
# misses — resolved per the carried fail-closed/fail-open policy with a
# WARNING, never a silent synchronous fallback.
# Entries bind nothing for that call instead (fail closed by construction).
# Same marker pattern as SkillToolPolicyMiddleware._REGISTRY_LOAD_FAILED.
_REGISTRY_LOAD_FAILED = object()
@ -134,7 +135,7 @@ class SkillActivationMiddleware(AgentMiddleware):
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:
def _activation_allowed(self, skill_name: str, *, activation_decisions: ActivationDecisions | None = None) -> bool:
"""Action-scoped ``skill:activate`` check for explicit slash activation.
``_available_skills`` is the Layer 1 *visibility* set (filter_resources,
@ -146,20 +147,26 @@ class SkillActivationMiddleware(AgentMiddleware):
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.
*activation_decisions* carries the per-step
:class:`~deerflow.authz.activation_decisions.ActivationDecisions`
batch precomputed on the event loop via ``aauthorize()``: a covered
name returns the batched decision; a miss inside the batch resolves
per the carried provider-error policy with a loud WARNING and never
touches a provider (wrong API from a worker thread for loop-affine
providers). ``None`` (the sync ``wrap_model_call`` chain, or
authorization disabled) is the only case that takes the synchronous
check below — the correct API there.
"""
if activation_decisions is not None and skill_name in activation_decisions:
return activation_decisions[skill_name]
if activation_decisions is not None:
# Construction-enforced: a miss inside an async batch resolves per
# the provider-error policy with a loud log — it never falls back
# to the synchronous authorize() from a worker thread.
return activation_decisions.decision_for(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:
async def _collect_activation_decisions(self, names) -> ActivationDecisions | None:
"""Precompute ``skill:activate`` decisions on the event loop.
Async middleware paths offload the blocking handler (skill-tree reads)
@ -173,13 +180,16 @@ class SkillActivationMiddleware(AgentMiddleware):
return None
candidates = {name for name in names if isinstance(name, str) and name}
if not candidates:
return None
# An empty batch is still a batch: consumers that resolve a name
# despite no candidates (a divergence) must fail per policy, not
# silently take the sync path.
return ActivationDecisions({}, fail_closed=self._skill_authorization.fail_closed)
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
return ActivationDecisions(decisions, fail_closed=self._skill_authorization.fail_closed)
def _candidate_activation_targets(self, request: ModelRequest) -> tuple[list[str], list[str]]:
"""Split the activation candidates into (names, entry_paths).
@ -289,7 +299,7 @@ class SkillActivationMiddleware(AgentMiddleware):
raise FileNotFoundError(resolved_file)
return resolved_file.read_text(encoding="utf-8")
def _resolve_activation(self, text: str, *, activation_decisions: dict[str, bool] | None = None) -> _ActivationResolution | None:
def _resolve_activation(self, text: str, *, activation_decisions: ActivationDecisions | None = None) -> _ActivationResolution | None:
reference = parse_slash_skill_reference(text)
if reference is None:
return None
@ -432,7 +442,7 @@ Follow this skill before choosing a general workflow. Load supporting resources
"""
return any(isinstance(message, AIMessage) for message in messages[target_index + 1 :])
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:
def _find_activation_target(self, messages: list, *, run_context: dict | None = None, activation_decisions: ActivationDecisions | None = None) -> tuple[int, HumanMessage, _ActivationResolution, str] | None:
if not messages:
return None
@ -513,7 +523,7 @@ 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, activation_decisions: dict[str, bool] | None = None) -> tuple[ModelRequest | AIMessage | None, _Activation | None]:
def _prepare_model_request(self, request: ModelRequest, *, hook: str, activation_decisions: ActivationDecisions | 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, activation_decisions=activation_decisions)
if target_and_resolution is None:
@ -559,7 +569,7 @@ Follow this skill before choosing a general workflow. Load supporting resources
request: ModelRequest,
*,
hook: str,
activation_decisions: dict[str, bool] | None = None,
activation_decisions: ActivationDecisions | 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)
@ -605,7 +615,7 @@ Follow this skill before choosing a general workflow. Load supporting resources
activation: _Activation | None,
*,
hook: str,
activation_decisions: dict[str, bool] | None = None,
activation_decisions: ActivationDecisions | None = None,
entry_registry: dict[str, Skill] | object | None = None,
) -> None:
"""Recompute the per-run secret injection set (binding point A+, #3861/#3914).
@ -806,7 +816,7 @@ Follow this skill before choosing a general workflow. Load supporting resources
request: ModelRequest,
registry: dict[str, Skill],
*,
activation_decisions: dict[str, bool] | None = None,
activation_decisions: ActivationDecisions | None = None,
exclude_names: frozenset[str] = frozenset(),
exclude_paths: frozenset[str] = frozenset(),
) -> list[tuple[str, tuple[SecretRequirement, ...]]]:
@ -920,7 +930,7 @@ Follow this skill before choosing a general workflow. Load supporting resources
request: ModelRequest,
entry_paths: list[str],
entry_registry: dict[str, Skill] | object | None,
activation_decisions: dict[str, bool] | None,
activation_decisions: ActivationDecisions | None,
) -> None:
"""Publish per-step ``skill:activate`` decisions keyed by entry path.
@ -937,20 +947,24 @@ Follow this skill before choosing a general workflow. Load supporting resources
decisions: dict[str, bool] = {}
registry = entry_registry if isinstance(entry_registry, dict) else {}
collected = activation_decisions or {}
collected = activation_decisions.get_or_none if activation_decisions is not None else (lambda _name: None)
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
decision = collected(skill.name) if skill is not None else None
decisions[normalized] = bool(decision) if decision 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:
def _collect_sync_activation_decisions(self, names: list[str]) -> ActivationDecisions | 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}
return ActivationDecisions(
{name: skill_activation_allowed(self._skill_authorization, name) for name in names},
fail_closed=self._skill_authorization.fail_closed,
)
@override
async def awrap_model_call(

View File

@ -17,6 +17,7 @@ from langchain_core.messages import ToolMessage
from langgraph.prebuilt.tool_node import ToolCallRequest
from langgraph.types import Command
from deerflow.authz.activation_decisions import ActivationDecisions
from deerflow.runtime.secret_context import SKILL_TOOL_POLICY_DECISION_CONTEXT_KEY, read_slash_skill_source_paths
from deerflow.skills.storage import get_or_new_skill_storage, get_or_new_user_skill_storage
from deerflow.skills.tool_policy import ALWAYS_AVAILABLE_BUILTIN_TOOL_NAMES, allowed_tool_names_for_skills
@ -36,9 +37,9 @@ _POLICY_SOURCES = frozenset({_POLICY_SOURCE_PASSIVE, _POLICY_SOURCE_SLASH, _POLI
_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
# would resolve skills whose names are absent from the (empty) decision batch —
# a policy-resolved miss (per the carried fail-closed/fail-open policy, with a
# WARNING), never a silent fallback. 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
@ -80,25 +81,33 @@ class SkillToolPolicyMiddleware(AgentMiddleware[AgentState]):
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:
def _activation_allowed(self, skill_name: str, *, activation_decisions: ActivationDecisions | 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.
*activation_decisions* carries the per-step
:class:`~deerflow.authz.activation_decisions.ActivationDecisions`
batch precomputed on the event loop via ``aauthorize()`` (async
hooks): a covered name returns the batched decision; a miss inside
the batch resolves per the carried provider-error policy with a loud
WARNING and never touches a provider. ``None`` (the sync hooks, or
authorization disabled) is the only case that takes the synchronous
check below — the correct API there.
"""
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]
if activation_decisions is not None:
# Construction-enforced: a miss inside an async batch resolves per
# the provider-error policy with a loud log — it never falls back
# to the synchronous authorize() from a worker thread.
return activation_decisions.decision_for(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:
async def _collect_activation_decisions(self, names) -> ActivationDecisions | None:
"""Precompute ``skill:activate`` decisions on the event loop (async hooks).
The policy resolution itself runs in a worker thread (storage reads),
@ -107,20 +116,22 @@ class SkillToolPolicyMiddleware(AgentMiddleware[AgentState]):
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).
here would surface as a batch miss at the consumer, which resolves
per the carried fail-closed/fail-open policy with a WARNING (denied
under the production default; allowed without any provider consult
under fail-open) — visible, but still a wiring bug to fix.
"""
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
# An empty batch is still a batch — consumers resolving an uncovered
# name must fail per policy, not silently take the sync path.
return ActivationDecisions(decisions, fail_closed=self._skill_authorization.fail_closed)
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.
@ -131,8 +142,10 @@ class SkillToolPolicyMiddleware(AgentMiddleware[AgentState]):
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:
resolve skills whose names are absent from the decision batch — a
policy-resolved miss: denied under the production fail-closed
default, allowed without any provider consult under fail-open, and
logged loudly either way). Unresolvable paths yield no name:
``_active_skills_for_paths`` skips them before the activation check,
so no decision is needed for them.
"""
@ -193,7 +206,7 @@ class SkillToolPolicyMiddleware(AgentMiddleware[AgentState]):
self,
paths: tuple[str, ...],
*,
activation_decisions: dict[str, bool] | None = None,
activation_decisions: ActivationDecisions | None = None,
registry: _RegistryArg = None,
) -> tuple[list[Skill], bool]:
if not paths:
@ -250,7 +263,7 @@ class SkillToolPolicyMiddleware(AgentMiddleware[AgentState]):
self,
paths: tuple[str, ...],
*,
activation_decisions: dict[str, bool] | None = None,
activation_decisions: ActivationDecisions | None = None,
registry: _RegistryArg = None,
) -> set[str] | None:
active_skills, policy_failed = self._active_skills_for_paths(paths, activation_decisions=activation_decisions, registry=registry)
@ -307,7 +320,7 @@ class SkillToolPolicyMiddleware(AgentMiddleware[AgentState]):
request: ModelRequest | ToolCallRequest,
*,
policy: _PolicySignature | None = None,
activation_decisions: dict[str, bool] | None = None,
activation_decisions: ActivationDecisions | None = None,
registry: _RegistryArg = None,
) -> set[str] | None:
resolved_policy = self._active_policy(request) if policy is None else policy
@ -324,7 +337,7 @@ class SkillToolPolicyMiddleware(AgentMiddleware[AgentState]):
*,
policy: _PolicySignature | None = None,
refresh_decision: bool = False,
activation_decisions: dict[str, bool] | None = None,
activation_decisions: ActivationDecisions | None = None,
registry: _RegistryArg = None,
) -> ModelRequest:
resolved_policy = self._active_policy(request) if policy is None else policy

View File

@ -0,0 +1,87 @@
"""Typed carrier for per-step ``skill:activate`` decisions (async hooks).
The async middleware hooks precompute ``skill:activate`` decisions on the
event loop with ``aauthorize()`` and hand them to the blocking handlers that
run in worker threads. Before this type existed the carrier was a plain
``dict | None``: ``None`` meant "sync chain — the synchronous ``authorize()``
is the correct API", and a name *missing* from a non-None map silently fell
back to the same synchronous call from the thread — the wrong API for
loop-affine providers, and under ``fail_closed: false`` a provider error
there flips a denial into an allow.
Four review rounds on #4541 traced four instances of that miss (wrong keys,
failed prepass, snapshot divergence, a consumer the prepass did not cover).
This type makes the contract construction-enforced instead of
caller-disciplined:
- ``None`` (absent) — sync chain; consumers call the synchronous
``skill_activation_allowed`` as before.
- :class:`ActivationDecisions` (present) — an async batch. A name it covers
returns the batched decision; a name it does NOT cover fails per the
configured provider-error policy with a loud log and **never** calls a
provider synchronously.
"""
from __future__ import annotations
import logging
from collections.abc import Mapping
logger = logging.getLogger(__name__)
class ActivationDecisions:
"""A per-model-step batch of ``skill:activate`` decisions.
``fail_closed`` carries the provider-error policy from the resolved
authorization context so a miss resolves exactly like a provider error
would (deny when fail-closed, allow when fail-open) — the difference
being that a miss is always a wiring bug, so it is logged loudly at
WARNING instead of silently shaping an allow/deny.
"""
__slots__ = ("_decisions", "_fail_closed")
def __init__(self, decisions: Mapping[str, bool], *, fail_closed: bool) -> None:
self._decisions = dict(decisions)
self._fail_closed = bool(fail_closed)
@property
def fail_closed(self) -> bool:
return self._fail_closed
def __contains__(self, name: str) -> bool:
return name in self._decisions
def __len__(self) -> int:
return len(self._decisions)
def decision_for(self, name: str) -> bool:
"""The batched decision for *name*; a miss fails per policy, loudly.
A miss means the prepass failed to cover a name its consumer
resolved — a construction bug, not a policy question. It resolves
like a provider error (fail-closed denies, fail-open allows) and
logs at WARNING so the divergence is visible in production logs
instead of silently exercising the wrong API.
"""
try:
return self._decisions[name]
except KeyError:
logger.warning(
"skill:activate decision for '%s' missing from the async batch; failing %s (prepass/consumer divergence — not a policy denial)",
name,
"closed (denied)" if self._fail_closed else "open (allowed)",
)
return not self._fail_closed
def get_or_none(self, name: str) -> bool | None:
"""The batched decision, or ``None`` when *name* is not covered.
For publishers that must distinguish "covered" from "miss" (e.g. the
rendered-reminder publication treats an uncovered path as hide).
"""
return self._decisions.get(name)
def __repr__(self) -> str: # pragma: no cover - debugging aid
return f"ActivationDecisions({self._decisions!r}, fail_closed={self._fail_closed!r})"

View File

@ -0,0 +1,54 @@
"""ActivationDecisions — the typed async decision carrier (construction hardening).
The carrier's contract (see ``deerflow/authz/activation_decisions.py``):
a covered name returns the batched decision; a MISS resolves per the
provider-error policy with a loud WARNING and never touches a provider —
``None`` remains the sync-chain signal where the synchronous authorize() is
the correct API.
"""
from __future__ import annotations
import logging
from deerflow.authz.activation_decisions import ActivationDecisions
def test_covered_name_returns_batched_decision():
decisions = ActivationDecisions({"alpha": True, "beta": False}, fail_closed=True)
assert decisions.decision_for("alpha") is True
assert decisions.decision_for("beta") is False
def test_miss_fails_closed_with_loud_log(caplog):
decisions = ActivationDecisions({}, fail_closed=True)
with caplog.at_level(logging.WARNING, logger="deerflow.authz.activation_decisions"):
assert decisions.decision_for("uncovered") is False
assert any("uncovered" in record.message and "denied" in record.message for record in caplog.records)
def test_miss_fails_open_with_loud_log(caplog):
decisions = ActivationDecisions({}, fail_closed=False)
with caplog.at_level(logging.WARNING, logger="deerflow.authz.activation_decisions"):
assert decisions.decision_for("uncovered") is True
assert any("uncovered" in record.message and "allowed" in record.message for record in caplog.records)
def test_get_or_none_distinguishes_miss_from_false():
decisions = ActivationDecisions({"alpha": False}, fail_closed=True)
assert decisions.get_or_none("alpha") is False
assert decisions.get_or_none("uncovered") is None
def test_container_protocol_and_len():
decisions = ActivationDecisions({"alpha": True}, fail_closed=True)
assert "alpha" in decisions
assert "uncovered" not in decisions
assert len(decisions) == 1

View File

@ -1723,11 +1723,14 @@ def test_async_tool_policy_uses_aauthorize(monkeypatch, tmp_path):
)
request = SimpleNamespace(state={"skill_context": [{"name": "demo-skill", "path": skill_path}]})
from deerflow.authz.activation_decisions import ActivationDecisions
async def _run():
return await middleware._collect_activation_decisions(["demo-skill"])
decisions = asyncio.run(_run())
assert decisions == {"demo-skill": True}
assert isinstance(decisions, ActivationDecisions)
assert decisions.decision_for("demo-skill") is True
# End to end through the async hook: the aauthorize-granted entry applies.
class _ToolsRequest(SimpleNamespace):
@ -3074,6 +3077,69 @@ def test_slash_dominance_anchors_on_path_across_midrun_rename(tmp_path, monkeypa
assert run_context.get(ACTIVE_SECRETS_CONTEXT_KEY) == {"NEW_KEY": "new-value"}
def test_async_batch_miss_never_calls_synchronous_provider():
"""[construction hardening] A name missing from an async batch resolves
per the provider-error policy WITHOUT consulting the synchronous
authorize() — the wrong API from a worker thread. Under the old dict
contract the miss silently took that path; with a loop-affine provider
and fail_closed=false it flipped denials into allows."""
from deerflow.agents.middlewares.skill_activation_middleware import SkillActivationMiddleware
from deerflow.authz.activation_decisions import ActivationDecisions
provider = _ActionAwareProvider(denied_activate=set())
resolved = _resolved_skill_authorization(provider, fail_closed=False)
middleware = SkillActivationMiddleware(
available_skills={"demo-skill"},
skill_authorization=resolved,
slash_source_owner_token="test-token",
)
empty_batch = ActivationDecisions({}, fail_closed=False)
# Miss under fail-open allows — and the sync API is never consulted
# (the old fallback would have recorded a sync call).
assert middleware._activation_allowed("uncovered", activation_decisions=empty_batch) is True
assert middleware._activation_allowed("demo-skill", activation_decisions=empty_batch) is True
assert provider.sync_calls == []
assert provider.async_calls == []
def test_async_batch_miss_fails_closed_under_default_policy():
"""The production default (fail_closed=True) denies a miss — loudly, and
still without touching the synchronous provider."""
from deerflow.agents.middlewares.skill_activation_middleware import SkillActivationMiddleware
from deerflow.authz.activation_decisions import ActivationDecisions
provider = _ActionAwareProvider(denied_activate=set())
resolved = _resolved_skill_authorization(provider, fail_closed=True)
middleware = SkillActivationMiddleware(
available_skills={"demo-skill"},
skill_authorization=resolved,
slash_source_owner_token="test-token",
)
empty_batch = ActivationDecisions({}, fail_closed=True)
assert middleware._activation_allowed("uncovered", activation_decisions=empty_batch) is False
assert provider.sync_calls == []
def test_sync_chain_none_still_uses_synchronous_api():
"""``None`` remains the sync-chain signal: the synchronous authorize() is
the correct API there and stays in use."""
provider = _ActionAwareProvider(denied_activate=set())
resolved = _resolved_skill_authorization(provider, fail_closed=True)
from deerflow.agents.middlewares.skill_activation_middleware import SkillActivationMiddleware
middleware = SkillActivationMiddleware(
available_skills={"demo-skill"},
skill_authorization=resolved,
slash_source_owner_token="test-token",
)
assert middleware._activation_allowed("demo-skill", activation_decisions=None) is True
assert provider.sync_calls == ["demo-skill"]
def posixpath_normpath(path: str) -> str:
import posixpath

View File

@ -1150,6 +1150,28 @@ Phase 1 最低验证要求:
- **复盘:** 本洞由"对刚写的修复立即执行新清单条目"抓出——第 41 条
(重攻自身修复)的直接收益。
### 2026-09-28 — 决策 map 构造性加固(#4541 review 线程预告的 follow-up)
- **背景:** 四轮 review(R5–R9 前 #4541 合并前后)追踪到同一失败类的四个实例:
异步执行路径上决策 map 的 miss 静默回退 worker 线程里的同步 `authorize()`——
对 loop-affine provider 是错误 API,`fail_closed=false` 时拒绝翻成放行。根因是
载体契约(`dict | None`)由调用方自觉维持:`None` = 同步链,miss = 回退同步。
- **决策(类型化载体):** `deerflow/authz/activation_decisions.py` 的
`ActivationDecisions`:覆盖名返回批量决策;**miss 按配置的 provider-error 策略
解析(fail-closed 拒 / fail-open 放)并以 WARNING 响亮记日志,从不触达
provider**——miss 是构造 bug 不是策略问题。`None` 保留为同步链信号(同步
`authorize()` 在同步链是正确 API)。空候选集返回空批而非 `None`:即便无候选,
消费者的意外名字也必须按策略失败。`get_or_none` 供发布者区分"未覆盖"与 False。
`fail_closed` 随载体携带(取自 resolved 授权上下文)。
- **证据:** `tests/test_activation_decisions.py` 5 个类型单测(覆盖返回/miss
双方向 + 响亮日志断言/get_or_none/容器协议)+ 中间件级 3 测试(miss 在
fail-open 允许且 `provider.sync_calls == []`、生产默认 fail-closed 拒绝、
`None` 同步链仍走同步 API)。突变 M18(恢复 miss 的同步回退)必红。
迁移后两中间件 + 既有套件 252 passed。
- **兼容性:** 同步链(`None`)行为逐字不变;批量内覆盖名的语义不变;仅 miss
的处置从"静默同步回退"变为"按策略失败 + 响亮日志"——正是四轮 review 追逐
的行为面。
### 新记录模板
```markdown