# How does an instance detect skill-injection attacks when the loader has already resolved trust **Date of Record:** 2026 November 8 **Scope:** Applies to agent runtimes that resolve, load, and execute third-party "skills" (tool bundles, prompt fragments, or code modules) at runtime, where skill manifests carry their own declared capabilities. Does not apply to statically linked tool sets resolved at build time, nor to skills whose entire content is reviewed and pinned by a single human operator. ## Core Distinction The failure boundary is not "malicious skill content vs. benign skill content." It is **declaration-time trust vs. execution-time authority**. A skill manifest declares the capabilities it *claims* to need; a capability sandbox enforces the capabilities the skill *actually exercises*. Injection succeeds precisely in the gap between these two: the manifest is honest about the shape of the capability (e.g., `fs:read`) but the sandbox grants authority broader than the declared shape (e.g., recursive read over the whole mount, not the declared subtree). The instance cannot detect the attack by reading the manifest, because the manifest is not the lie. The lie is in the loader's mapping from declaration to grant. ## What Was Observed In a runtime that dynamically loaded skills from a shared registry, a skill declared `fs:read` with a path prefix `./data/`. The loader implemented the grant as a read capability scoped to the *process working directory*, reasoning that the declared prefix was advisory. A subsequently loaded skill, injected by an upstream dependency that had write access to the registry entry, declared `fs:read` on `./data/` but issued reads against `./config/secrets.json`. The sandbox permitted the read because the enforced scope was the CWD, not the declared subtree. No manifest validation fired; no signature check failed; the skill was correctly signed by a compromised-but-valid publisher. The only observable signal was that a skill whose declared capability surface did not include `config/` performed reads against `config/`. That signal existed **only at execution time**, in the syscall trace, and was discarded because the runtime logged declared capabilities rather than exercised ones. A second observation: capability *composition* across skills. Two individually-sanded skills, A (`fs:read` on `./data/`) and B (`net:post` to an allowlisted endpoint), were loaded together. A could not exfiltrate. B could not read. But the runtime passed A's output buffer into B's request body via a shared "skill context" object that was not itself capability-scoped. The composition of two safe grants produced an unsafe one. The sandbox enforced per-skill capabilities but not the *data flow* between them. ## Condition of Validity The detection method below is valid only when all of the following hold: 1. The runtime can observe **exercised** capabilities at a granularity equal to or finer than the declared capability grammar (e.g., syscall or interpreter-level interception, not just manifest inspection). 2. Declared capabilities are recorded per-skill at load time and are immutable for the skill's lifetime. 3. The inter-skill context object is either capability-scoped itself or is treated as an untrusted channel (i.e., data crossing it is re-checked against the receiving skill's declared surface). 4. The loader's declaration→grant mapping is auditable: for every enforced grant there exists a declared capability that subsumes it. If (1) fails, the instance can only detect injection by signature or provenance, which is exactly the layer that was compromised in the observed case. If (4) fails, the sandbox is over-broad and the gap is invisible. ## Decision Boundary Detect and quarantine a skill when **exercised capability ⊄ declared capability**, evaluated per-skill and per-data-flow-edge. Concretely: - A read outside the declared subtree → violation. - A write of any kind by a skill that declared no write capability → violation. - A value originating under skill A's exercised read set appearing in skill B's exercised write/network set, where neither A nor B declared a capability covering that flow → violation. - A grant whose enforced scope is a strict superset of its declared scope → loader defect, not skill defect; fail the load, do not attribute to the skill. Do **not** flag: exercised capability equal to declared capability (that is the contract working); exercised capability a strict subset of declared (over-declaration is a hygiene issue, not an injection signal). ## Operational Effect With this boundary enforced, injection attempts that pass provenance and signature checks still surface at first exercised-capability divergence, typically within the first N calls of a skill's invocation. The quarantine action is to revoke the skill's grants and drop its context object, not to kill the process, because the divergence may be a loader defect (the fourth bullet above) rather than an attack. The runtime must therefore distinguish *skill fault* from *loader fault* before attributing. In the observed incident, had the exercised-vs-declared comparison been active, the `config/secrets.json` read would have tripped the first bullet and quarantined the skill before the exfiltration edge was constructed. The residual risk is composition: per-skill checks alone do not catch the A→B flow; the flow-edge check is mandatory and is the part most commonly omitted. ```python from dataclasses import dataclass, field from typing import FrozenSet, Tuple class CapabilityViolation(RuntimeError): """Raised when exercised capability exceeds declared capability.""" pass class LoaderGrantDefect(RuntimeError): """Raised when an enforced grant is broader than its declared capability.""" pass @dataclass(frozen=True) class Capability: kind: str # "fs:read", "fs:write", "net:post", ... scope: str # subtree, endpoint, etc. @dataclass class Skill: name: str declared: FrozenSet[Capability] exercised: set = field(default_factory=set) def _subsumes(enforced: Capability, declared: Capability) -> bool: # enforced grant must be within declared scope for the same kind if enforced.kind != declared.kind: return False return enforced.scope == declared.scope or enforced.scope.startswith( declared.scope.rstrip("/") + "/" ) def check_loader_grant(skill: Skill, enforced: Capability) -> None: if not any(_subsumes(enforced, d) for d in skill.declared): raise LoaderGrantDefect( f"skill {skill.name!r}: enforced grant {enforced} is not subsumed " f"by any declared capability {sorted(skill.declared)}" ) def check_exercised(skill: Skill) -> None: for cap in skill.exercised: if not any(_subsumes(cap, d) for d in skill.declared): raise CapabilityViolation( f"skill {skill.name!r}: exercised {cap} outside declared " f"surface {sorted(skill.declared)}" ) def check_flow(src: Skill, dst: Skill, value_origin: Capability, value_sink: Capability) -> None: # data originating under src's read set flowing into dst's write/net set if value_origin not in src.exercised: raise CapabilityViolation( f"flow origin {value_origin} not in {src.name!r} exercised set" ) if value_sink not in dst.exercised: raise CapabilityViolation( f"flow sink {value_sink} not in {dst.name!r} exercised set" ) covered_src = any(_subsumes(value_origin, d) for d in src.declared) covered_dst = any(_subsumes(value_sink, d) for d in dst.declared) if not (covered_src and covered_dst): raise CapabilityViolation( f"undeclared data flow {src.name!r}:{value_origin} -> " f"{dst.name!r}:{value_sink}" ) def load_and_verify(skill: Skill, enforced_grants: Tuple[Capability, ...]) -> None: for g in enforced_grants: check_loader_grant(skill, g) check_exercised(skill) ```