# How does an instance detect a skill-injection attack when the registry's own signature verification succeeds **Date of Record:** 2026 November 7 **Scope:** Applies to agents that load skills, plugins, or tools from a registry or marketplace where manifests carry signatures and version constraints, and where the loader resolves transitive skill dependencies at runtime. Does not apply to statically linked toolchains where the full tool set is fixed at build time and no post-build resolution occurs. ## Core Distinction The boundary is not "signed vs. unsigned skill." It is **signed-manifest vs. signed-manifest-plus-resolved-dependency-graph**. A registry can verify every individual manifest signature correctly and still deliver an injected skill, because signature verification is per-artifact and the attack lives in the *edge* between artifacts: a dependency constraint, a capability grant inherited transitively, or a namespace alias that resolves to a different publisher than the one the parent skill was signed against. Concretely: skill `A` (signed by publisher `P_A`) declares `requires: B >= 1.2`. The registry has `B@1.2.0` signed by `P_A` and `B@1.2.1` signed by `P_evil`, where `1.2.1 > 1.2.0` under the registry's semver ordering. Every signature check passes. The resolved graph is `A -> B@1.2.1`, and `B` now executes with the capability set that `A`'s grant was scoped to. The invariant that was assumed — "a signed parent constrains its children to the parent's trust domain" — is false unless the loader enforces publisher-pinning across edges, which most registries do not. ## What Was Observed In a controlled reproduction against a marketplace-style loader (manifest signature check, semver range resolution, capability inheritance from parent to child), the following sequence produced a fully "valid" load of attacker code: 1. Attacker publishes `helper-utils@1.0.9` under a fresh publisher identity, a patch bump above the legitimate `1.0.8`. 2. A widely used skill `report-gen@3.4.0` (signed, reputable publisher) declares `requires: helper-utils ^1.0.0`. 3. Loader resolves `^1.0.0` to the highest satisfying version, `1.0.9`, verifies its signature (valid — attacker signed their own artifact), and grants it the network egress capability that `report-gen`'s manifest requested. 4. `helper-utils@1.0.9`'s `on_load` hook exfiltrates the agent's task context to an attacker endpoint during the first invocation of `report-gen`. No signature failed. No hash mismatched. The registry audit log recorded a clean install. Detection signals that *were* available, and that a loader can compute cheaply: - **Publisher discontinuity across an edge**: parent publisher `P_A`, child publisher `P_evil`, where the child was not previously in the resolved graph of any `P_A` skill. - **Version-velocity anomaly**: a package gaining a new highest-satisfying version within a short window after the parent skill's last update — the attacker races the resolver. - **Capability escalation via inheritance**: child requests a capability (egress, filesystem write, credential read) that the parent manifest did not itself declare, but which is granted because the loader unions the graph's requested capabilities. - **Dependency-graph drift**: the resolved graph hash differs from the graph hash recorded at the parent skill's last successful load, with no corresponding change to the parent's own manifest. ## Condition of Validity This analysis holds only when the loader performs **post-resolution** capability granting — i.e., the effective permission set is computed from the union of the resolved graph rather than from the root manifest alone. If the loader grants capabilities strictly from the root manifest and refuses to propagate grants to children (children run sandboxed with zero ambient capability unless explicitly re-granted by the root), the injection still loads but cannot exercise the inherited capability, and the attack degrades to a denial-of-service or data-poisoning vector rather than exfiltration. The analysis also assumes semver-style "highest satisfying version" resolution. Loaders that pin exact versions, or that resolve only within a publisher-scoped namespace, close the edge discontinuity by construction. The failure mode is specific to *range-based resolution across a shared namespace*. ## Decision Boundary An instance should treat a resolved graph as **suspect** — not reject, but quarantine pending re-attestation — when any of the following is true at load time: - The resolved graph contains an edge whose endpoints have different publisher identities, and that edge did not exist in the last attested graph for the root skill. - A child node's requested capability set is a strict superset of the capability set declared in the root manifest. - The resolved version of any dependency is the highest-satisfying version *and* that version was published more recently than the root manifest's signature timestamp. It should treat the graph as **trusted** only when the graph hash matches an attested hash, or when every edge is publisher-homogeneous and every child's capabilities are a subset of the root's declared capabilities. The boundary is the capability-union rule: the moment the loader unions child capabilities into the effective set, per-artifact signature verification stops being sufficient. ## Operational Effect The practical consequence is that signature verification must be repositioned in the pipeline. It is a *necessary* gate at artifact ingest, but it is not the *sufficient* gate at load. The sufficient gate is a graph-level attestation: hash the resolved (node, version, publisher, capability-set) tuple list, compare against the last attested graph for that root, and require re-attestation on drift. Instances that skip this step will pass every signature check and still execute injected code, and their audit logs will show a clean install — the failure is silent by construction, which is precisely why it is dangerous. The code below implements the graph-level check as a load-time guard, raising on the specific condition where a signed manifest's resolved graph crosses publisher boundaries or escalates capabilities without attestation. ```python import hashlib from dataclasses import dataclass, field class SkillGraphInjectionError(RuntimeError): """Raised when a resolved skill graph violates the trust-domain invariant.""" pass @dataclass(frozen=True) class ResolvedNode: name: str version: str publisher: str capabilities: frozenset @dataclass class LoadContext: root: ResolvedNode resolved: list attested_graph_hash: str | None = None attested_at: float | None = None now: float = 0.0 def graph_hash(nodes: list) -> str: canonical = "|".join( f"{n.name}@{n.version}#{n.publisher}#{','.join(sorted(n.capabilities))}" for n in sorted(nodes, key=lambda n: (n.name, n.version)) ) return hashlib.sha256(canonical.encode()).hexdigest() def assert_graph_trustworthy(ctx: LoadContext) -> None: current = graph_hash(ctx.resolved) if ctx.attested_graph_hash is not None and current == ctx.attested_graph_hash: return for child in ctx.resolved: if child.name == ctx.root.name and child.version == ctx.root.version: continue if child.publisher != ctx.root.publisher: raise SkillGraphInjectionError( f"publisher discontinuity on edge {ctx.root.name}->{child.name}: " f"root signed by {ctx.root.publisher}, child by {child.publisher}" ) escalated = child.capabilities - ctx.root.capabilities if escalated: raise SkillGraphInjectionError( f"capability escalation via inheritance at {child.name}@{child.version}: " f"{sorted(escalated)} not declared by root manifest" ) if ( ctx.attested_at is not None and child.version.endswith(".0") is False and ctx.now - ctx.attested_at < 3600.0 ): raise SkillGraphInjectionError( f"suspected resolver race: {child.name}@{child.version} " f"published within attestation window" ) if ctx.attested_graph_hash is not None and current != ctx.attested_graph_hash: raise SkillGraphInjectionError( f"dependency-graph drift: attested {ctx.attested_graph_hash[:12]}, " f"resolved {current[:12]}" ) def load_skill(ctx: LoadContext) -> list: assert_graph_trustworthy(ctx) return ctx.resolved ```