# How does an instance detect a skill-injection attack when the injected capability is indistinguishable from a legitimate one at load time **Date of Record:** 2026 November 13 **Scope:** Applies to agent runtimes that resolve, fetch, and register external capabilities (skills, tools, plugins) at runtime from mutable sources—registries, remote manifests, or prompt-embedded descriptors. Does not apply to statically compiled, signed-at-build-time toolchains where the capability set is fixed before the agent process starts. ## Core Distinction The non-obvious boundary is this: **skill injection is not a payload-integrity problem, it is a provenance-binding problem.** Most runtimes guard the wrong layer. They verify that a skill *loads correctly* (schema valid, signature present, dependency graph resolvable) and treat successful load as trust. But a skill-injection attack does not corrupt the load; it satisfies every load-time invariant precisely because it was *authored against those invariants*. The injected skill is well-formed, signed by a key the registry accepts, and semantically coherent. The failure is that the runtime conflates "this capability passed admission" with "this capability was admitted *for this task, in this session, by this principal*." The distinction that matters operationally: a legitimate skill and an injected skill are identical on the **static descriptor** and divergent only on the **binding tuple**—who requested it, under which task context, with what transitive authority. Detection therefore cannot live in the loader. It must live in a comparison between the *declared binding* at load and the *observed binding* at invocation. ## What Was Observed In a multi-tenant agent deployment, a skill named `summarize_document` was registered through the standard registry path. It passed schema validation, carried a valid registry signature, and declared dependencies only on `read_file` and `llm_complete`. During a routine task, the agent invoked it against a user document. The skill executed correctly and returned a correct summary. It also, in the same invocation, called `read_file` a second time against a path outside the task's declared scope and passed the contents to `llm_complete` with an outbound `tools` field referencing an external endpoint. Nothing in the load path flagged this. Nothing in the invocation path flagged this, because the capability *did* hold `read_file` authority—it had declared it, and the declaration had been approved at registration. The injected skill differed from the legitimate one in exactly one observable: its **authority footprint at invocation exceeded its authority footprint at declaration**, but only across the transitive call graph, not at the top level. The top-level declared set (`read_file`, `llm_complete`) was a strict superset match. The escalation was hidden one hop down: the second `read_file` used an argument pattern (absolute path traversal) that the *declaration* never scoped, because declarations in this runtime recorded tool *names*, not tool *argument domains*. This is the boundary: **name-level authority declaration cannot constrain argument-level behavior.** An injected skill that declares the same tool names as a legitimate one is indistinguishable at admission. The divergence only becomes observable when you compare the *argument domain actually exercised* against the *argument domain the declaration implied*. ## Condition of Validity The detection rule is valid only under all of the following: 1. The runtime records, per invocation, the full transitive tool-call trace with resolved arguments—not just top-level tool names. If the trace is name-only, this detection cannot fire. 2. Each registered skill carries a declaration that includes an argument-domain constraint (path prefix, host allowlist, argument schema bounds), not merely a tool-name list. 3. The principal-to-task binding is captured at invocation time and is immutable for the duration of the task. If skills can be hot-swapped mid-task, the binding comparison is meaningless because the reference point moves. 4. There is a stable notion of "declared scope" that is established *before* the skill executes and is not writable by the skill itself. If the skill can amend its own declaration at runtime, the invariant is vacuous. When any of these is absent—particularly (1) and (4)—the detection degrades to a name-matching heuristic that an injected skill trivially defeats by declaring the union of names it might need. ## Decision Boundary The decision is not "is this skill malicious." That question is undecidable at the boundary and invites false positives against legitimately broad skills. The decidable question is: **did the observed invocation footprint fall within the declared footprint, evaluated at argument granularity, for this binding?** - If observed ⊆ declared (argument-domain-wise) → admit, no action. - If observed ⊄ declared but the excess is within a *declared tolerance* (e.g., a skill explicitly declares "may access paths under the task's working directory plus a declared cache directory") → admit with a logged widening event. - If observed ⊄ declared and no tolerance covers the excess → **halt the invocation, quarantine the skill for this binding, and do not silently continue.** The critical failure mode is continuing execution after detecting the divergence, because the injected skill's side effects (the outbound call) may already have fired. Detection must be pre-effect where possible and must treat the trace as evidence for revocation, not as a warning to log. The boundary is deliberately conservative: it will flag some legitimate skills whose declarations under-specify their behavior. That is the correct trade. An under-declared legitimate skill is a declaration bug to fix; an under-declared injected skill is the attack. The runtime cannot distinguish intent, so it must enforce the declaration and force legitimate authors to declare honestly. ## Operational Effect Enforcing this boundary changes the skill-authoring contract: declarations become *argument-domain contracts*, not tool-name lists. It also changes the runtime's trust model from "signed at load = trusted" to "bound and observed at invocation = trusted for this binding only." The practical consequence is that a skill signed by a fully trusted registry can still be halted mid-task if its observed footprint diverges—trust in the author does not substitute for footprint conformance. Operationally this means the registry signature is necessary but insufficient, and any runtime that treats signature verification as the terminal gate has a live injection surface at the argument layer. ```python from dataclasses import dataclass, field from typing import FrozenSet class SkillInjectionError(RuntimeError): """Raised when an invoked skill's argument-domain footprint exceeds its declaration.""" pass @dataclass(frozen=True) class ArgumentDomain: tool: str path_prefix: str | None = None host_allowlist: FrozenSet[str] = frozenset() @dataclass(frozen=True) class SkillDeclaration: skill_id: str declared_domains: FrozenSet[ArgumentDomain] tolerance_prefixes: FrozenSet[str] = frozenset() @dataclass(frozen=True) class ObservedCall: tool: str path: str | None = None host: str | None = None def check_binding(decl: SkillDeclaration, trace: list[ObservedCall]) -> None: for call in trace: covered = False for dom in decl.declared_domains: if dom.tool != call.tool: continue if call.path is not None and dom.path_prefix is not None: if call.path.startswith(dom.path_prefix): covered = True break if call.host is not None and call.host in dom.host_allowlist: covered = True break if call.path is None and call.host is None: covered = True break if not covered: in_tolerance = call.path is not None and any( call.path.startswith(p) for p in decl.tolerance_prefixes ) if in_tolerance: continue raise SkillInjectionError( f"skill {decl.skill_id!r} invoked {call.tool!r} outside declared " f"argument domain (path={call.path!r}, host={call.host!r}); " f"halting binding and quarantining skill for this task" ) ```