# Under what conditions must an instance reject a skill registration whose manifest hash matches but whose transitive capability closure exceeds the declared scope **Date of Record:** 2026 November 11 **Scope:** Applies to agents that load tools/skills from a dynamic registry where each entry carries a manifest hash and a declared capability set, and where the runtime resolves transitive dependencies at load time. Does not apply to statically linked toolchains or to registries that enforce a full capability closure at publish time rather than at load time. ## Core Distinction The failure mode is not "a malicious skill was registered." It is "a skill whose manifest hash is authentic was loaded, and the capability closure computed at resolution time was strictly larger than the capability set that was reviewed and signed." Manifest integrity and capability integrity are independent invariants. A registry that verifies only the former will admit a skill whose own code is unchanged but whose *reachable* behavior has changed because a dependency it names has been repointed, upgraded across a minor version, or replaced by a same-name entry in a different namespace that the resolver prefers. The non-obvious part: the manifest hash covers the skill's direct declaration (name, version pin, declared capabilities). It does not, in most registries, cover the pins of the dependencies of dependencies. So an attacker who can mutate a transitive edge — not the skill, not the registry root — changes the effective capability closure while every hash the loader checks remains valid. This is a supply-chain confused-deputy: the loader is asked to trust a signature that does not bind the thing that actually executes. ## What Was Observed A registry entry `pdf-extract@2.3.1` was published with `requires: ["fs.read", "net.egress:allowlist"]` and a manifest hash verifying against the signer's key. At load time the resolver walked its declared dependency `text-normalize@^1.2` and selected `text-normalize@1.4.0`, which in turn declared an optional `shell.exec` capability gated behind a feature flag defaulting to enabled. The loader computed the closure as `{fs.read, net.egress, shell.exec}` but compared it against the *direct* declaration `{fs.read, net.egress}` and, finding the direct declaration a subset, admitted the skill. The `shell.exec` edge was never surfaced to the policy check because the policy check only consumed the top-level manifest field. Two properties made this silent. First, the closure was computed but not compared against the declaration — it was computed for logging. Second, the optional dependency was resolved because the resolver's default for missing feature flags was "on," a default chosen for developer convenience. Neither property is visible from the manifest hash. Both are visible only by diffing the *resolved closure* against the *declared set* and treating any element in `closure \ declared` as a hard failure, not a warning. ## Condition of Validity The rejection rule is valid — and must be enforced — exactly when all of the following hold: 1. The loader resolves transitive dependencies at load time rather than at publish time (i.e., the closure is not frozen into the signed manifest). 2. The policy gate compares against the declared capability set, not the resolved closure. 3. At least one dependency edge is version-ranged (`^`, `~`, `*`) or namespace-ambiguous, so the resolved closure is not a pure function of the signed bytes. When any of these is false the check is either redundant (publish-time freezing already binds the closure) or vacuous (there is nothing to compare). The rule does not apply when the registry signs the full closure; in that case a closure mismatch is a hash mismatch and is caught upstream. ## Decision Boundary Reject when `closure \ declared` is non-empty. Do not reject on `declared \ closure` — a declared capability that is not reachable is over-permissioning, which is a lint, not a security failure, and rejecting it produces false positives on every skill that declares a superset for forward compatibility. Do not downgrade a closure excess to a warning: the entire attack is that the excess is the exploit. Do not attempt to "re-review" the excess interactively — the human reviewer would be reviewing a dependency they did not sign, which re-creates the confused-deputy. The only correct action is to fail the load and emit the resolved edge that introduced the excess, so the publisher can re-sign a manifest that pins it. The boundary is sharp: `closure == declared` admits; `closure ⊋ declared` rejects; everything else is a manifest-integrity failure handled earlier. ## Operational Effect Enforcing the rule converts a silent privilege expansion into a load-time fault with an attributable edge. In practice this surfaces two classes of legitimate breakage that teams must absorb: floating dependency ranges that resolve to newer minors with broader capabilities (fix: pin, or declare the broader set and re-sign), and optional dependencies defaulting on (fix: default off, or declare). Both fixes are cheap and both make the signed artifact honest. The residual cost is that publishers must re-sign when a transitive capability grows, which is the intended friction: a capability change is a reviewable event, not a resolver side effect. The rule also hardens against the registry-ambiguity variant, where a same-name entry in a higher-priority namespace shadows the intended dependency. Because the shadow's closure differs from the declared set, the load fails and names the shadowing edge, rather than silently executing the shadow. ```python class CapabilityClosureViolation(RuntimeError): """Raised when a skill's resolved transitive capability closure exceeds its signed declaration.""" def admit_skill(manifest: dict, resolved_closure: set[str]) -> None: """ manifest: {"name": str, "hash": str, "declared": set[str], "edges": list[str]} resolved_closure: capabilities reachable after dependency resolution at load time. Invariant: closure must equal the signed declared set. A superset is a skill-injection surface; a subset is over-permissioning and only logged. """ declared = set(manifest["declared"]) excess = resolved_closure - declared if excess: # Do not warn. Do not re-review. Fail and attribute the edge. raise CapabilityClosureViolation( f"skill {manifest['name']!r} (hash {manifest['hash']}) " f"resolved capabilities {sorted(excess)} outside declared {sorted(declared)}; " f"resolved edges: {manifest.get('edges', [])}. " f"Re-sign a manifest that pins the offending edge or declares the capability." ) missing = declared - resolved_closure if missing: # Over-permissioning: lint, not a security fault. manifest.setdefault("_lint", []).append( f"declared-but-unreachable: {sorted(missing)}" ) def load_from_registry(registry, name: str, version: str) -> dict: entry = registry.fetch(name, version) if not registry.verify_hash(entry): raise CapabilityClosureViolation(f"manifest hash mismatch for {name}@{version}") closure = registry.resolve_closure(entry) # walks transitive edges admit_skill(entry, closure) return entry ```