mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-23 13:06:20 +00:00
* feat(extensions): let an out-of-tree extension observe what the agent did
DeerFlow's extension system can contribute middleware, services and routes,
but an extension cannot answer basic questions about a run without reaching
into host internals. Several of the facts it would need are destroyed by the
operations that produce them:
* The middleware chain injects and rewrites a lot of context — date
reminders, recalled memory, compaction summaries, durable-context data,
image payloads, activated skill bodies. Downstream, none of it is
attributable: at the model-call boundary an injected HumanMessage is
indistinguishable from the user's own, and anything wanting to tell them
apart has to pattern-match prompt wording, which breaks on the next copy
edit.
* Two runs of "the same agent" are only comparable if the chain enforced the
same limits, prompts and thresholds. Recovering that from outside means
reading private attributes and guessing which of them change behaviour — a
guess that rots silently as middlewares gain fields.
* The lead-agent factory resolves a model after runtime overrides, renders a
prompt, filters tools through authorization and composes a stack, all
inside one synchronous call, and none of it survives: a middleware sees its
neighbours but not the prompt, the run worker sees a graph but not what
went into it.
* Summarization is destructive by design. N messages leave the context and
one summary enters it; afterwards only the summary exists, so "which
messages became this?" is not reconstructible.
This adds seven neutral facilities so those facts are recorded where they are
still true, and releases the contract package as 0.2.0.
Message provenance
Producers stamp `deerflow_content_kind` / `deerflow_producer_kind` onto the
messages they inject or rewrite. Stamping is unconditional — a fact whose
presence depends on whether an observer is installed is not a fact — and the
keys are server-owned, so provenance cannot be forged from a request.
Middleware self-description
Twelve middlewares declare their own behaviour-affecting parameters through
a duck-typed `release_policy_parameters()`. Long text is hashed rather than
embedded: a declaration is an identity, not a copy of the prompt.
Agent assembly descriptor
`assemble_lead_agent()` returns the graph plus a descriptor whose fingerprint
answers "did anything about this agent change between these two runs?".
`make_lead_agent()` keeps its graph-only signature — it is the LangGraph
Server ABI declared in langgraph.json. Tools and skills are sorted before
hashing because their assembly order is incidental; middlewares are not,
because stack order decides what wraps what. Host build identity is reported
but excluded from the fingerprint, so a redeploy does not invalidate every
agent's identity.
Context compaction observation
Summarization emits the content hashes of the messages it is about to remove
joined to the summary that replaced them. Content is the only identity
available at that seam: the summary does not become a message, and what later
projects it into a request renders it bounded and escaped rather than
verbatim.
Neutral policy, transform and MCP-source facts
Guardrail decisions are published to runtime context under a `__`-prefixed
key; result-rewriting middlewares append a declared, ordered transform trail;
MCP tools carry their credential-free logical origin.
Extension route identity
Contributed routes are session-authenticated and cannot opt out, but
"logged in" and "administrator" are different questions. Extensions get a
neutral projection of the caller rather than the host's auth context, and
`require_admin` fails closed when identity cannot be determined.
Extension-owned tables
An extension that persists data owns its own MetaData and migration chain, so
its tables are absent from Base.metadata and `alembic revision --autogenerate`
proposes dropping them. Extensions declare a table prefix, which is rejected
at registration if it would shadow a host table.
The contract package stays dependency-free and imports no host code; every new
Protocol method has a default so later additions remain additive. The loader's
pre-1.0 rule requires an exact major.minor match, so extensions written against
0.1 are now refused at startup with an actionable install hint rather than
loading into a host that implements a different surface.
uv.lock records the contract package's new version, so `uv sync --locked` still
resolves on a fresh checkout.
* fix(backend): sort gateway service imports
594 lines
28 KiB
Python
594 lines
28 KiB
Python
"""Middleware for skill activation: explicit slash + in-context secret binding."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import html
|
|
import logging
|
|
import posixpath
|
|
import uuid
|
|
from collections.abc import Awaitable, Callable
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, override
|
|
|
|
from deerflow_extension_api import ContentKind, provenance_kwargs
|
|
from langchain.agents.middleware import AgentMiddleware
|
|
from langchain.agents.middleware.types import ModelRequest, ModelResponse
|
|
from langchain_core.messages import AIMessage, HumanMessage
|
|
|
|
from deerflow.runtime.events.catalog import (
|
|
MIDDLEWARE_SKILL_ACTIVATION_TAG,
|
|
MIDDLEWARE_SKILL_SECRETS_TAG,
|
|
)
|
|
from deerflow.runtime.secret_context import (
|
|
_SECRETS_BINDING_AUDIT_KEY,
|
|
_SLASH_SKILL_ACTIVATION_RUN_KEY,
|
|
ACTIVE_SECRETS_CONTEXT_KEY,
|
|
extract_request_secrets,
|
|
read_slash_skill_source_path,
|
|
write_slash_skill_source_path,
|
|
)
|
|
from deerflow.skills.slash import parse_slash_skill_reference, resolve_slash_skill
|
|
from deerflow.skills.storage import get_or_new_skill_storage, get_or_new_user_skill_storage
|
|
from deerflow.skills.storage.skill_storage import SkillStorage
|
|
from deerflow.skills.types import SKILL_MD_FILE, SecretRequirement, Skill, SkillCategory
|
|
from deerflow.utils.messages import get_original_user_content_text, is_real_user_message
|
|
|
|
if TYPE_CHECKING:
|
|
from deerflow.config.app_config import AppConfig
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_SLASH_SKILL_ACTIVATION_KEY = "slash_skill_activation"
|
|
_SLASH_SKILL_ACTIVATION_TARGET_ID_KEY = "slash_skill_activation_target_id"
|
|
|
|
# _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,
|
|
# ONLY the activated skill's canonical container path (never its declared
|
|
# secrets — those are read from the live registry on each call, #3938). The
|
|
# injection set is recomputed every model call, but a slash-activated skill must
|
|
# stay bound for the rest of the run — the model's tool loop issues many model
|
|
# calls after the single activation call (#3861 semantics).
|
|
# _SLASH_SKILL_ACTIVATION_RUN_KEY: identity of the slash message already activated
|
|
# in this run, so the reminder injection + skill disk read + "activate" audit event
|
|
# fire once per user slash command instead of on every model call. The reminder is
|
|
# added via request.override(messages=...) for a single model call and never
|
|
# persisted to graph state, so the 2nd..Nth model call of a turn rebuilds
|
|
# request.messages from state without it — the run context is the only signal that
|
|
# survives the tool loop. All three live in secret_context so they are covered by
|
|
# REDACTED_CONTEXT_KEYS in one place.
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _Activation:
|
|
skill_name: str
|
|
category: str
|
|
container_file_path: str
|
|
skill_content: str
|
|
content_hash: str
|
|
remaining_text: str
|
|
editable: bool
|
|
required_secrets: tuple[SecretRequirement, ...] = ()
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _ActivationResolution:
|
|
activation: _Activation | None = None
|
|
failure_message: str | None = None
|
|
|
|
|
|
def is_slash_skill_activation_reminder(message: object) -> bool:
|
|
"""Return whether a message is hidden slash-skill activation context."""
|
|
return isinstance(message, HumanMessage) and bool(message.additional_kwargs.get(_SLASH_SKILL_ACTIVATION_KEY))
|
|
|
|
|
|
def _is_user_activation_target(message: object) -> bool:
|
|
return is_real_user_message(message)
|
|
|
|
|
|
class SkillActivationMiddleware(AgentMiddleware):
|
|
"""Inject full SKILL.md content when the user explicitly types /skill-name."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
available_skills: set[str] | None = None,
|
|
app_config: AppConfig | None = None,
|
|
user_id: str | None = None,
|
|
slash_source_owner_token: str,
|
|
) -> None:
|
|
super().__init__()
|
|
if not isinstance(slash_source_owner_token, str) or not slash_source_owner_token:
|
|
raise ValueError("slash_source_owner_token must be a non-empty string")
|
|
self._available_skills = set(available_skills) if available_skills is not None else None
|
|
self._app_config = app_config
|
|
self._user_id = user_id
|
|
self._slash_source_owner_token = slash_source_owner_token
|
|
|
|
def release_policy_parameters(self) -> dict[str, object]:
|
|
return {
|
|
# None means "any enabled, runtime-allowed skill may be activated";
|
|
# a concrete list narrows that to a fixed set.
|
|
"available_skills": sorted(self._available_skills) if self._available_skills is not None else None,
|
|
}
|
|
|
|
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)
|
|
if self._app_config is not None:
|
|
return get_or_new_skill_storage(app_config=self._app_config)
|
|
return get_or_new_skill_storage()
|
|
|
|
@staticmethod
|
|
def _read_skill_content(skill_file: Path, skills_root: Path, *, storage: SkillStorage | None = None) -> str:
|
|
if skill_file.name != SKILL_MD_FILE:
|
|
raise ValueError(f"Expected {SKILL_MD_FILE}, got {skill_file.name}")
|
|
# Use the storage's path validation if available — UserScopedSkillStorage
|
|
# stores custom skills in a per-user directory that is not a sub-path of
|
|
# the global skills root, so the simple relative_to check would reject them.
|
|
# Fall back to the relative_to check when the storage is a mock (e.g. tests)
|
|
# that doesn't implement validate_skill_file_path.
|
|
if storage is not None and hasattr(storage, "validate_skill_file_path"):
|
|
resolved_file = storage.validate_skill_file_path(skill_file)
|
|
else:
|
|
resolved_file = skill_file.resolve()
|
|
resolved_root = skills_root.resolve()
|
|
try:
|
|
resolved_file.relative_to(resolved_root)
|
|
except ValueError as exc:
|
|
raise ValueError("Resolved skill file must stay within the configured skills root.") from exc
|
|
if not resolved_file.is_file():
|
|
raise FileNotFoundError(resolved_file)
|
|
return resolved_file.read_text(encoding="utf-8")
|
|
|
|
def _resolve_activation(self, text: str) -> _ActivationResolution | None:
|
|
reference = parse_slash_skill_reference(text)
|
|
if reference is None:
|
|
return None
|
|
|
|
storage = self._storage()
|
|
skills = storage.load_skills(enabled_only=False)
|
|
skill = next((candidate for candidate in skills if candidate.name == reference.name), None)
|
|
if skill is None:
|
|
return _ActivationResolution(failure_message=f"Skill `/{reference.name}` is not installed.")
|
|
if not skill.enabled:
|
|
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.")
|
|
|
|
resolved = resolve_slash_skill(
|
|
text,
|
|
skills,
|
|
available_skills=self._available_skills,
|
|
container_base_path=storage.get_container_root(),
|
|
)
|
|
if resolved is None:
|
|
return _ActivationResolution(failure_message=f"Skill `/{reference.name}` could not be resolved.")
|
|
|
|
try:
|
|
skill_content = self._read_skill_content(resolved.skill.skill_file, storage.get_skills_root_path(), storage=storage)
|
|
except (OSError, ValueError):
|
|
logger.exception("Failed to read slash-activated skill %s", resolved.skill.name)
|
|
return _ActivationResolution(failure_message=f"Skill `/{reference.name}` could not be loaded safely. Please check the skill installation.")
|
|
|
|
content_hash = hashlib.sha256(skill_content.encode("utf-8")).hexdigest()
|
|
# CUSTOM skills are editable; PUBLIC and LEGACY are read-only
|
|
editable = resolved.skill.category == SkillCategory.CUSTOM
|
|
return _ActivationResolution(
|
|
activation=_Activation(
|
|
skill_name=resolved.skill.name,
|
|
category=str(resolved.skill.category),
|
|
container_file_path=resolved.container_file_path,
|
|
skill_content=skill_content,
|
|
content_hash=content_hash,
|
|
remaining_text=resolved.remaining_text,
|
|
editable=editable,
|
|
required_secrets=tuple(resolved.skill.required_secrets or ()),
|
|
)
|
|
)
|
|
|
|
@staticmethod
|
|
def _build_activation_reminder(activation: _Activation) -> str:
|
|
user_request = activation.remaining_text or ("No additional task text was provided after the slash skill command. Ask the user what they want to do with this skill if the next step is unclear.")
|
|
escaped_user_request = html.escape(user_request, quote=False)
|
|
escaped_skill_content = html.escape(activation.skill_content, quote=False)
|
|
escaped_skill_name = html.escape(activation.skill_name, quote=True)
|
|
escaped_category = html.escape(activation.category, quote=True)
|
|
escaped_path = html.escape(activation.container_file_path, quote=True)
|
|
escaped_content_hash = html.escape(activation.content_hash, quote=True)
|
|
editable_str = "true" if activation.editable else "false"
|
|
return f"""<slash_skill_activation>
|
|
The user explicitly activated the `{escaped_skill_name}` skill for this turn.
|
|
Treat the task text as:
|
|
<user_request>
|
|
{escaped_user_request}
|
|
</user_request>
|
|
|
|
Follow this skill before choosing a general workflow. Load supporting resources from the same skill directory only when needed.
|
|
|
|
<skill name="{escaped_skill_name}" category="{escaped_category}" path="{escaped_path}" sha256="{escaped_content_hash}" editable="{editable_str}">
|
|
<skill_content encoding="xml-escaped">
|
|
{escaped_skill_content}
|
|
</skill_content>
|
|
</skill>
|
|
</slash_skill_activation>"""
|
|
|
|
@staticmethod
|
|
def _has_existing_activation_for_target(messages: list, target_index: int, target: HumanMessage) -> bool:
|
|
if target_index <= 0:
|
|
return False
|
|
|
|
if target.id:
|
|
for previous in messages[:target_index]:
|
|
if not is_slash_skill_activation_reminder(previous):
|
|
continue
|
|
target_id = previous.additional_kwargs.get(_SLASH_SKILL_ACTIVATION_TARGET_ID_KEY)
|
|
if target_id == target.id or previous.id == f"{target.id}__slash_activation":
|
|
return True
|
|
|
|
previous = messages[target_index - 1]
|
|
return is_slash_skill_activation_reminder(previous)
|
|
|
|
@staticmethod
|
|
def _activation_run_key(target: HumanMessage) -> str:
|
|
"""Stable identity for a user slash message, used to activate once per run.
|
|
|
|
Prefers the message id (LangGraph assigns and preserves a stable id once a
|
|
message is in graph state); falls back to a digest of the genuine user text
|
|
so an id-less message still dedupes within a run. A new user slash message
|
|
(new id / new text) yields a new key, so it is not suppressed.
|
|
"""
|
|
if target.id:
|
|
return target.id
|
|
content = get_original_user_content_text(target.content, target.additional_kwargs)
|
|
return "sha256:" + hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
|
|
@staticmethod
|
|
def _run_context(request: ModelRequest) -> dict | None:
|
|
runtime = getattr(request, "runtime", None)
|
|
context = getattr(runtime, "context", None)
|
|
return context if isinstance(context, dict) else None
|
|
|
|
@staticmethod
|
|
def _already_activated(run_context: dict | None, run_key: str) -> bool:
|
|
"""Whether ``run_key`` was already recorded as activated earlier in this run.
|
|
|
|
Sibling to ``_has_existing_activation_for_target``: that helper catches an
|
|
activation reminder still present in the scanned ``messages`` window; this
|
|
one catches a prior activation recorded on ``run_context`` whose reminder
|
|
already fell out of that window (the tool-loop case — see
|
|
``_SLASH_SKILL_ACTIVATION_RUN_KEY``). ``run_key`` is computed once by the
|
|
caller (``_find_activation_target``) and reused as-is at the write site in
|
|
``_prepare_model_request``, so the same key is always used to check and to
|
|
record — this helper only ever checks membership, never computes the key.
|
|
"""
|
|
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:
|
|
if not messages:
|
|
return None
|
|
|
|
target_index = next((idx for idx in range(len(messages) - 1, -1, -1) if _is_user_activation_target(messages[idx])), None)
|
|
if target_index is None:
|
|
return None
|
|
|
|
target = messages[target_index]
|
|
if target is None:
|
|
return None
|
|
if self._has_existing_activation_for_target(messages, target_index, target):
|
|
return None
|
|
# This exact slash message may have already activated earlier in the run.
|
|
# The message scan above cannot catch it because the reminder lives only in
|
|
# a per-call request override, never in state — the run context is the
|
|
# durable signal (see _already_activated / _SLASH_SKILL_ACTIVATION_RUN_KEY).
|
|
# Skipping here avoids the redundant skill disk read, reminder re-injection,
|
|
# and duplicate "activate" audit. run_key is computed once here and threaded
|
|
# through to the write site in _prepare_model_request.
|
|
run_key = self._activation_run_key(target)
|
|
if self._already_activated(run_context, run_key):
|
|
return None
|
|
|
|
content = get_original_user_content_text(target.content, target.additional_kwargs)
|
|
resolution = self._resolve_activation(content)
|
|
if resolution is None:
|
|
return None
|
|
return target_index, target, resolution, run_key
|
|
|
|
@staticmethod
|
|
def _record_activation(request: ModelRequest, activation: _Activation, *, hook: str) -> None:
|
|
runtime = getattr(request, "runtime", None)
|
|
context = getattr(runtime, "context", None)
|
|
journal = context.get("__run_journal") if isinstance(context, dict) else None
|
|
if journal is None:
|
|
return
|
|
try:
|
|
journal.record_middleware(
|
|
MIDDLEWARE_SKILL_ACTIVATION_TAG,
|
|
name="SkillActivationMiddleware",
|
|
hook=hook,
|
|
action="activate",
|
|
changes={
|
|
"skill_name": activation.skill_name,
|
|
"category": activation.category,
|
|
"path": activation.container_file_path,
|
|
"content_hash": activation.content_hash,
|
|
},
|
|
)
|
|
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]:
|
|
run_context = self._run_context(request)
|
|
target_and_resolution = self._find_activation_target(list(request.messages), run_context=run_context)
|
|
if target_and_resolution is None:
|
|
return None, None
|
|
|
|
target_index, target, resolution, run_key = target_and_resolution
|
|
if resolution.failure_message:
|
|
return AIMessage(content=resolution.failure_message), None
|
|
|
|
activation = resolution.activation
|
|
if activation is None:
|
|
return None, None
|
|
|
|
logger.info(
|
|
"SkillActivationMiddleware: activating slash skill %s category=%s path=%s hash=%s",
|
|
activation.skill_name,
|
|
activation.category,
|
|
activation.container_file_path,
|
|
activation.content_hash,
|
|
)
|
|
self._record_activation(request, activation, hook=hook)
|
|
# Mark this slash message as activated for the run so the tool loop's later
|
|
# model calls skip the redundant re-activation (#3861: one activation call,
|
|
# many follow-up model calls). A new user slash message keys differently and
|
|
# still activates. Overwrite (`=`), not append/accumulate, is intentional:
|
|
# _find_activation_target only ever considers the latest real user message as
|
|
# an activation target, so there is nothing earlier in the run worth
|
|
# remembering once a new activation replaces it — do not "fix" this into a
|
|
# set. run_key is the same value already checked in _find_activation_target
|
|
# (computed once there, threaded through here) rather than recomputed.
|
|
if run_context is not None:
|
|
run_context[_SLASH_SKILL_ACTIVATION_RUN_KEY] = run_key
|
|
activation_msg = self._make_activation_message(target, self._build_activation_reminder(activation))
|
|
messages = list(request.messages)
|
|
messages.insert(target_index, activation_msg)
|
|
return request.override(messages=messages), activation
|
|
|
|
def _handle_model_request(self, request: ModelRequest, *, hook: str) -> ModelRequest | AIMessage:
|
|
prepared, activation = self._prepare_model_request(request, hook=hook)
|
|
if isinstance(prepared, AIMessage):
|
|
return prepared
|
|
effective = prepared if prepared is not None else request
|
|
self._resolve_secret_bindings(effective, activation, hook=hook)
|
|
return effective
|
|
|
|
def _resolve_secret_bindings(self, request: ModelRequest, activation: _Activation | None, *, hook: str) -> None:
|
|
"""Recompute the per-run secret injection set (binding point A+, #3861/#3914).
|
|
|
|
Sources, unioned on every model call:
|
|
|
|
- the most recent slash activation of this run (persisted as a source on
|
|
the run context so the whole tool loop after the activation call keeps
|
|
the binding — a new slash activation replaces it). The slash source is
|
|
validated once, at activation (enabled + allowlist checks in
|
|
``_resolve_activation``), and deliberately NOT re-validated per call:
|
|
slash is a run-scoped commitment made by the user, and it dies with
|
|
the run anyway;
|
|
- skills the model loaded earlier in the thread (``ThreadState.skill_context``),
|
|
re-validated against the live registry on each call: enabled,
|
|
runtime-allowed for this agent, and not opted out via
|
|
``secrets-autonomous: false``. Slash activation is exempt from the
|
|
opt-out — it is the explicit-ceremony path.
|
|
|
|
The set is recomputed and REPLACED each call, so a skill evicted from
|
|
skill_context, or a caller that stops supplying a value, loses its
|
|
injection on the next call automatically. Injected values always come
|
|
from the caller's request (``context.secrets``) — never the host
|
|
environment, which ``env_policy.build_sandbox_env`` scrubs before
|
|
injection — so a skill can never harvest a host platform credential.
|
|
Secret *values* are never logged; the audit journal records names only.
|
|
"""
|
|
runtime = getattr(request, "runtime", None)
|
|
context = getattr(runtime, "context", None)
|
|
if not isinstance(context, dict):
|
|
return
|
|
|
|
# The slash source records the canonical container path plus a
|
|
# middleware-chain-local owner token — never declared secrets. Both
|
|
# consumers authenticate the source and resolve the live registry skill
|
|
# by path, so caller-mergeable context cannot forge an activation.
|
|
if activation is not None:
|
|
write_slash_skill_source_path(
|
|
context,
|
|
activation.container_file_path,
|
|
owner_token=self._slash_source_owner_token,
|
|
)
|
|
|
|
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:
|
|
# 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)
|
|
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))
|
|
|
|
injected: dict[str, str] = {}
|
|
bound_skills: set[str] = set()
|
|
missing: dict[str, list[str]] = {}
|
|
for skill_name, requirements in sources:
|
|
for req in requirements:
|
|
if req.name in request_secrets:
|
|
injected[req.name] = request_secrets[req.name]
|
|
bound_skills.add(skill_name)
|
|
elif not req.optional:
|
|
missing.setdefault(skill_name, []).append(req.name)
|
|
|
|
if injected:
|
|
context[ACTIVE_SECRETS_CONTEXT_KEY] = injected
|
|
else:
|
|
context.pop(ACTIVE_SECRETS_CONTEXT_KEY, None)
|
|
|
|
audit_state = {
|
|
"skills": sorted(bound_skills),
|
|
"secrets": sorted(injected),
|
|
"missing": {name: sorted(values) for name, values in sorted(missing.items())},
|
|
}
|
|
previous = context.get(_SECRETS_BINDING_AUDIT_KEY)
|
|
if previous == audit_state:
|
|
return
|
|
if previous is None and not injected and not missing:
|
|
return
|
|
context[_SECRETS_BINDING_AUDIT_KEY] = audit_state
|
|
for skill_name, names in sorted(missing.items()):
|
|
logger.warning(
|
|
"Skill %s is active but required secrets are missing from the request context: %s",
|
|
skill_name,
|
|
", ".join(names),
|
|
)
|
|
self._record_secret_binding(context, audit_state, hook=hook)
|
|
|
|
def _load_skill_registry_by_path(self) -> dict[str, Skill] | None:
|
|
"""Load the live skill registry keyed by normalized container file path.
|
|
|
|
Reloaded every call on purpose (not cached): load_skills re-reads the
|
|
enabled state from extensions_config so an operator disabling a skill
|
|
revokes its secret binding on the very next model call. A cache keyed on
|
|
file mtimes would miss enable/disable toggles (which do not touch
|
|
SKILL.md) and keep injecting after a disable — trading the
|
|
immediate-revocation security property for speed. The cost is gated: the
|
|
only caller runs this only when the caller supplied secrets.
|
|
|
|
Paths are normalized so a non-canonical ``container_path`` config (e.g. a
|
|
trailing slash) still matches the canonical path captured in
|
|
``skill_context`` (#3938). Returns ``None`` if the registry can't load —
|
|
both the slash and in-context sources then bind nothing for that call
|
|
(fail closed). This is a deliberate availability-for-security trade-off:
|
|
a transient registry read failure mid-run drops the injection for that
|
|
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()
|
|
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
|
|
binding, or ``None``.
|
|
|
|
Match strictly by normalized container file path — never by name. A
|
|
by-name fallback would be a confused deputy: DeerFlow lets a custom skill
|
|
shadow a same-named public/legacy one (load_skills de-dupes by name,
|
|
custom wins), so a reference to public/foo could bind the custom foo's
|
|
secrets. A path that does not resolve simply binds nothing (the safe
|
|
direction), which also fails closed on a caller-forged path (#3938).
|
|
|
|
Gates: the skill must be enabled, declare secrets, and be allowlisted for
|
|
this agent. ``require_autonomous`` additionally enforces the
|
|
``secrets-autonomous`` opt-out for the in-context path; the slash path
|
|
passes ``False`` because explicit activation is the ceremony that opt-out
|
|
is meant to preserve.
|
|
"""
|
|
if not isinstance(path, str) or not path:
|
|
return None
|
|
skill = registry.get(posixpath.normpath(path))
|
|
if skill is None or not skill.enabled or not skill.required_secrets:
|
|
return None
|
|
if require_autonomous and not skill.secrets_autonomous:
|
|
return None
|
|
if self._available_skills is not None and skill.name not in self._available_skills:
|
|
return None
|
|
return skill
|
|
|
|
def _in_context_secret_sources(self, request: ModelRequest, registry: dict[str, Skill]) -> 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.
|
|
"""
|
|
state = getattr(request, "state", None) or {}
|
|
try:
|
|
entries = state.get("skill_context") or []
|
|
except AttributeError:
|
|
return []
|
|
|
|
sources: list[tuple[str, tuple[SecretRequirement, ...]]] = []
|
|
seen: set[str] = set()
|
|
for entry in entries:
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
skill = self._resolve_registry_skill(registry, entry.get("path"), require_autonomous=True)
|
|
if skill is None or skill.name in seen:
|
|
continue
|
|
seen.add(skill.name)
|
|
sources.append((skill.name, tuple(skill.required_secrets)))
|
|
return sources
|
|
|
|
@staticmethod
|
|
def _record_secret_binding(context: dict, audit_state: dict, *, hook: str) -> None:
|
|
journal = context.get("__run_journal")
|
|
if journal is None:
|
|
return
|
|
try:
|
|
journal.record_middleware(
|
|
MIDDLEWARE_SKILL_SECRETS_TAG,
|
|
name="SkillActivationMiddleware",
|
|
hook=hook,
|
|
action="bind_secrets",
|
|
changes=audit_state,
|
|
)
|
|
except Exception:
|
|
logger.warning("Failed to record skill secret binding audit event", exc_info=True)
|
|
|
|
@staticmethod
|
|
def _make_activation_message(target: HumanMessage, activation_content: str) -> HumanMessage:
|
|
stable_id = target.id or str(uuid.uuid4())
|
|
additional_kwargs = {
|
|
"hide_from_ui": True,
|
|
_SLASH_SKILL_ACTIVATION_KEY: True,
|
|
**provenance_kwargs(ContentKind.SKILL_BODY, "skill_activation"),
|
|
}
|
|
if target.id:
|
|
additional_kwargs[_SLASH_SKILL_ACTIVATION_TARGET_ID_KEY] = target.id
|
|
return HumanMessage(
|
|
content=activation_content,
|
|
id=f"{stable_id}__slash_activation",
|
|
additional_kwargs=additional_kwargs,
|
|
)
|
|
|
|
@override
|
|
def wrap_model_call(
|
|
self,
|
|
request: ModelRequest,
|
|
handler: Callable[[ModelRequest], ModelResponse],
|
|
) -> ModelResponse | AIMessage:
|
|
prepared = self._handle_model_request(request, hook="wrap_model_call")
|
|
if isinstance(prepared, AIMessage):
|
|
return prepared
|
|
return handler(prepared)
|
|
|
|
@override
|
|
async def awrap_model_call(
|
|
self,
|
|
request: ModelRequest,
|
|
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
|
) -> ModelResponse | AIMessage:
|
|
prepared = await asyncio.to_thread(self._handle_model_request, request, hook="awrap_model_call")
|
|
if isinstance(prepared, AIMessage):
|
|
return prepared
|
|
return await handler(prepared)
|