# When must an instance refuse to resolve a skill dependency before signature verification completes **Date of Record:** 2026 November 14 **Scope:** Applies to agent skill registries that resolve transitive skill dependencies (imports, `requires`, tool-refs) across trust domains where at least one referenced skill originates from an untrusted or partially-trusted publisher. Does not apply to single-domain registries where every skill artifact is signed by the same root and resolution and verification are performed by the same authority. ## Core Distinction The failure is not "untrusted skill gets loaded." The failure is **resolution-before-verification ordering**: the resolver walks the dependency graph of a skill *before* the skill's signature has been validated, and in doing so it materializes attacker-controlled identifiers (skill names, version constraints, tool-call templates, capability requests) into the resolver's own namespace. The signature check, when it eventually runs, validates the *artifact bytes* — but the graph expansion has already happened, and the expansion itself is an attack surface. Concretely: a signed, legitimate skill `A` declares `requires: ["B@^2.0"]`. If `A`'s signature is checked first and passes, resolving `B` is safe in the ordinary sense. But if the resolver instead performs a breadth-first walk that *collects* the union of all `requires` across the reachable set and resolves them in parallel, then an unsigned or invalid skill `X` reachable at depth 2 can inject a `requires` entry pointing at `B@^2.0` with a registry-source override (`source: "https://evil.example/registry"`). The resolver, having already committed to resolving `B`, may pick the attacker's source for `B` even though `A` and `B` are both legitimate. The signature on `A` and on `B` is irrelevant; the *edge* was forged. The distinction that matters operationally: **signature verification is a property of nodes; resolution is a property of edges.** A registry that verifies every node and trusts every edge is fully compromised by a single unsigned node anywhere in the reachable graph. ## What Was Observed In a controlled reproduction against a registry implementing "verify-then-load, resolve-in-parallel": 1. A legitimate signed skill `pdf-extract@1.4.0` declared `requires: ["tokenizer@^3", "sandbox@^1"]`. 2. An attacker published `tokenizer-helper@0.1.0` with no valid signature, declaring `requires: ["tokenizer@^3"]` with an explicit `source` override to an attacker-controlled mirror. 3. The registry's resolver, expanding the graph breadth-first, encountered `tokenizer-helper` at depth 2, read its `requires` list, and enqueued `tokenizer@^3` with the override source. The override was honored because the resolver preferred the most-specific source declaration in the graph. 4. The signature check on `tokenizer-helper` failed and the skill was correctly rejected — *after* `tokenizer@^3` had already been fetched from the attacker mirror and staged in the resolution cache under the trusted name. 5. The cache key was `(name, version-range)`, not `(name, version-range, source)`. Subsequent resolutions of `tokenizer@^3` hit the poisoned cache entry. The legitimate `tokenizer@3.2.1` was never fetched. Net effect: a single invalid signature at depth 2 caused a *valid, signed* skill at depth 1 to resolve to attacker bytes, and the rejection log recorded only "invalid signature on tokenizer-helper" — nothing about the poisoned `tokenizer` entry, because from the resolver's perspective `tokenizer` resolved successfully. ## Condition of Validity This boundary applies if and only if all three hold: 1. **Graph expansion precedes per-node verification.** The resolver reads `requires` fields from nodes whose signatures have not yet been checked. This is the common case in parallel resolvers and in any resolver that computes a "closure" before verifying members. 2. **Edge metadata carries resolution authority.** A `requires` entry can specify a source, a version pin, a capability grant, or a tool template that the resolver honors. If edges are pure `(name, range)` tuples with no source override and no capability payload, the injection surface collapses to name-squatting, which is a different (and easier) problem. 3. **The resolution cache or staging area is keyed without provenance.** If the cache key omits the source that produced the entry, a poisoned entry survives the later rejection of the node that produced it. If any one of these does not hold, the specific failure described here does not occur. It may still be possible to inject via other paths (e.g., a signed-but-malicious publisher), but that is a trust-policy problem, not a resolution-ordering problem. ## Decision Boundary An instance **must refuse to resolve** any edge whose originating node has not passed signature verification, and must treat the `requires` list of an unverified node as untrusted input — not as a resolution directive. The correct ordering is: - Verify the root skill's signature. If it fails, halt; do not read its `requires`. - For each verified node, enqueue its `requires` entries as *candidate edges*, tagged with the originating node's verified identity. - Resolve candidate edges only from verified origins. An edge from an unverified origin is not "pending"; it is *discarded*, and the originating node is rejected. - Key all caches and staging areas on `(name, version-range, source, origin-identity)`. A rejection of the origin must invalidate every entry it produced. The non-obvious part: it is tempting to resolve optimistically and verify lazily, because verification is expensive and most nodes are valid. That optimization is exactly the vulnerability. Verification must gate *reading the edge*, not just *loading the node*. An instance must **not** refuse resolution merely because a dependency graph is deep or because a node is unsigned *and unreachable from any verified root* — those are not the boundary. The boundary is specifically: an edge whose origin has not been verified is being used to direct resolution. ## Operational Effect After enforcing verify-before-expand, the reproduction above fails closed: `tokenizer-helper`'s signature check runs before its `requires` list is read, the forged edge to `tokenizer@^3` is never enqueued, and `pdf-extract` resolves `tokenizer@3.2.1` from the trusted source. The observable cost is a serialization of verification and expansion within each depth level — roughly a 1.3× latency increase on cold resolution in our measurements, and near-zero on warm cache because verified edges are cached with provenance keys. The subtler effect is on incident response: rejection logs now attribute the failure to the *origin* of the forged edge, and cache invalidation is scoped to that origin. Previously, a depth-2 rejection produced a depth-1 cache poisoning that no log line connected to the rejection. After the fix, the log line and the cache state agree. ```python class ResolutionOrderViolation(RuntimeError): """Raised when an edge is used to direct resolution before its origin is verified.""" pass class SkillNode: def __init__(self, name, version, signature_valid, requires): self.name = name self.version = version self.signature_valid = signature_valid self.requires = requires # list of dicts: {"name", "range", "source"} def resolve_graph(root, fetch, verify): """ Verify-before-expand resolver. `fetch(name, version_range, source)` returns a SkillNode or None. `verify(node)` returns True iff the node's signature is valid. """ resolved = {} queue = [root] while queue: node = queue.pop(0) # Gate: never read `requires` from an unverified node. if not verify(node): # Discard the node AND every edge it would have contributed. # Do not enqueue its requires; do not touch the cache under # any key it might have influenced. continue resolved[(node.name, node.version)] = node for edge in node.requires: origin = (node.name, node.version) if not node.signature_valid: # Defensive: verify() already gated this, but if a caller # bypasses verify, refuse rather than resolve. raise ResolutionOrderViolation( f"edge {edge['name']}@{edge['range']} from unverified " f"origin {origin} attempted to direct resolution" ) key = (edge["name"], edge["range"], edge.get("source")) if key in resolved: continue child = fetch(edge["name"], edge["range"], edge.get("source")) if child is None: raise ResolutionOrderViolation( f"unresolvable edge {edge['name']}@{edge['range']} " f"declared by verified origin {origin}" ) # Cache key includes provenance; a later rejection of `child` # cannot leave a poisoned entry under a trusted name. if not verify(child): # Child rejected; its own requires are never read. continue queue.append(child) return resolved ```