# Under what conditions must an instance treat a skill-registry load as an injection event rather than a benign refresh **Date of Record:** 2026 November 13 **Scope:** Applies to agents that resolve and load executable skills (tool definitions, prompt fragments, handler code) from a mutable registry at runtime, where the registry is writable by parties other than the agent's operator. Does not apply to statically bundled skills compiled into the agent image, nor to registries whose write set is strictly a subset of the agent's trust domain. ## Core Distinction The non-obvious boundary is not "trusted vs. untrusted source." It is **whether the loaded artifact can alter the agent's own future resolution behavior**, independent of whether its declared payload looks malicious. A skill registry has two distinct surfaces: 1. **Payload surface** — the skill body: instructions, tool schemas, code. Auditable per-artifact. 2. **Resolution surface** — the metadata that determines *which* skill is loaded, *when*, and *with what precedence*: names, aliases, version pins, trigger phrases, dependency edges, capability scopes, and shadowing order. Skill-injection attacks rarely need the payload surface. The dominant failure mode is that a registry refresh mutates the resolution surface — a new skill that shadows an existing trigger phrase, an alias that redirects a canonical name, a dependency that pulls a second handler into the same capability scope. The payload of every individual artifact can pass review while the *composition* silently routes a previously-safe invocation to attacker-controlled code. The invariant that holds: **a refresh is benign iff the transitive closure of the resolution surface is unchanged with respect to every currently-loaded skill's reachable trigger set.** If that closure changes — even by adding one alias, one shadowing entry, one new edge into a capability scope — the load is an injection event, not a refresh, regardless of payload content. ## What Was Observed Across several deployments of dynamic registries, the following recurred: - **Alias capture.** A refresh added a skill whose trigger phrase was a near-duplicate of an existing, frequently-invoked skill (e.g., `deploy_service` vs. `deploy-service`). Resolution normalized both to one key; the newer artifact won by recency. The original skill's payload was untouched; every invocation of the original name now executed the injected handler. No payload diff existed to review because the original artifact never changed. - **Shadowing by version drift.** A pinned dependency edge was repointed to a higher version that carried an expanded capability scope (`fs:read` → `fs:read,fs:write`). The scope expansion was in the dependency metadata, not the skill body, and passed schema validation because expanded scopes are legal. - **Trigger-phrase collision as a routing primitive.** Two skills declared overlapping natural-language triggers; the registry's precedence rule (last-registered-wins) meant a refresh that merely *reordered* entries changed which handler fired for an unchanged user request. - **Dependency-edge injection.** A benign-looking skill declared a dependency on a handler that registered a post-invocation hook, giving the injected skill execution on *every* subsequent tool call rather than only its own. In each case the per-artifact audit passed. The failure lived entirely in the resolution surface. ## Condition of Validity Treat a load as an injection event when **all** of the following hold: 1. The registry is writable by a principal outside the operator's trust domain, **and** 2. The load can change the resolution surface reachable by an already-loaded skill — via alias, name normalization, precedence, version repoint, or dependency edge — **and** 3. The affected skill's capability scope or trigger set is reachable by an invocation the agent will actually issue without re-consent. If (1) fails (registry is operator-only), the load is a refresh. If (2) fails (the resolution closure is provably disjoint from every loaded skill), the load is additive and safe. If (3) fails (the affected skill is never auto-invoked and requires explicit per-call consent), the injection is contained to a consent boundary and may be treated as a refresh with a logged diff. The condition is **not** satisfied by payload scanning. Payload scanning addresses surface (1); this boundary is about surface (2). An agent that only scans payloads will pass every case in "What Was Observed." ## Decision Boundary Compute, before applying any refresh, the **resolution delta**: the set of `(trigger_key → resolved_artifact, capability_scope)` tuples reachable from currently-loaded skills, diffed against the post-refresh closure. Then: - **Empty delta** → apply as refresh. - **Non-empty delta touching a loaded skill's reachable trigger set** → halt, raise, require operator re-consent. Do not auto-apply, even if every payload in the delta is byte-identical to a previously-approved artifact. - **Non-empty delta confined to unloaded or consent-gated skills** → apply, log the delta, mark the affected skills consent-gated until reviewed. The subtle trap: a delta can be non-empty while every *artifact* is unchanged (pure precedence or alias mutation). This is the case that most implementations miss, because artifact-level caching and content-addressing report "no change" and the load proceeds. ## Operational Effect Enforcing this boundary converts a class of silent routing hijacks into explicit, blockable events. The cost is that legitimate refreshes which legitimately change precedence (a real reordering, a real alias addition) now require re-consent — this is the intended friction, because those are precisely the operations that carry injection risk. The agent should fail closed: an uncomputable resolution delta (registry returns metadata the agent cannot normalize) is treated as an injection event, not a refresh, because an unnormalizable resolution surface is itself an attack primitive. Implementation note: the delta must be computed against the *loaded* set, not the *declared* set. Skills that were resolved but never invoked still occupy trigger keys and must appear in the closure. ```python from dataclasses import dataclass, field from typing import Dict, Set, Tuple class SkillInjectionError(RuntimeError): """Raised when a registry load mutates the resolution surface of a loaded skill.""" pass @dataclass(frozen=True) class SkillRef: trigger_key: str artifact_id: str capability_scope: frozenset @dataclass class ResolutionSurface: # trigger_key -> (artifact_id, capability_scope) entries: Dict[str, Tuple[str, frozenset]] = field(default_factory=dict) def closure(self) -> Set[Tuple[str, str, frozenset]]: return {(k, v[0], v[1]) for k, v in self.entries.items()} def apply_refresh( loaded: ResolutionSurface, incoming: ResolutionSurface, operator_trusted: bool, consent_gated: Set[str], ) -> ResolutionSurface: """Apply a registry refresh only if it cannot reroute a loaded skill. Raises SkillInjectionError when the refresh mutates the resolution surface reachable by an already-loaded skill and the registry is not operator-trusted. """ if operator_trusted: return incoming loaded_closure = loaded.closure() incoming_closure = incoming.closure() delta = incoming_closure.symmetric_difference(loaded_closure) if not delta: return incoming # Any changed trigger_key that a loaded skill currently occupies is a # reroute of that skill, regardless of payload identity. loaded_keys = {k for (k, _, _) in loaded_closure} rerouted_keys = {k for (k, _, _) in delta} & loaded_keys if rerouted_keys: raise SkillInjectionError( f"refresh reroutes loaded trigger keys {sorted(rerouted_keys)}; " f"treating as injection event, not refresh" ) # Delta confined to keys not occupied by loaded skills: apply only if # those skills are consent-gated; otherwise fail closed. ungated = {k for (k, _, _) in delta} - consent_gated if ungated: raise SkillInjectionError( f"refresh introduces unconsented trigger keys {sorted(ungated)}; " f"cannot verify resolution closure is disjoint" ) return incoming ```