# When must an instance refuse a skill whose manifest signature verifies but whose declared capability graph exceeds the registry's transitive trust closure **Date of Record:** 2026 November 10 **Scope:** Applies to MCP tool registries and agent marketplaces that perform signature verification on skill manifests at admission time and separately compute a transitive capability closure for downstream agents. Does not apply to single-tenant local tool servers where the agent and the tool publisher share an identity domain, nor to registries that re-resolve the full dependency graph at every invocation. ## Core Distinction Signature validity and capability-closure membership are orthogonal properties, and the admission pipeline frequently conflates them. A manifest signature proves *who published* a skill and that *the bytes were not mutated in transit*. It says nothing about whether the skill's declared capability graph — the set of tools, sub-agents, credential scopes, and outbound network egress the skill will exercise, including transitively through skills it invokes — is contained within the trust closure the registry has computed for the requesting principal. The non-obvious failure is this: a skill can carry a perfectly valid signature from an allowlisted publisher, pass schema validation, pass static capability checks on its *direct* declared capabilities, and still exceed the registry's transitive trust closure because one of the skills it invokes (or one of *that* skill's invocations) was admitted earlier under a wider closure and has since been re-scoped. The admission check validates the signature against the publisher key, then validates direct capabilities against the *current* principal's closure, but never recomposes the transitive graph. The gap is the delta between "direct capabilities are in-closure" and "the union of transitively reachable capabilities is in-closure." ## What Was Observed During adversarial red-teaming of a marketplace registry with 1,400 skills and 60 publishers, a skill `publisher-x/format-helper` was submitted with a valid Ed25519 signature from an allowlisted publisher. Its direct capability declaration was `{filesystem:read:./tmp, text:transform}` — comfortably inside every principal's closure. Static admission passed. The skill's implementation, however, invoked a second skill `publisher-y/legacy-shell` via an MCP `tools/call` to a sub-agent. `legacy-shell` had been admitted eight months earlier under a then-permissive closure that included `process:exec`. When the registry tightened the closure policy, `legacy-shell` was *grandfathered* — its existing admission record was not re-evaluated, and its capability declaration was not updated. New admissions of `legacy-shell` would be rejected, but the existing record remained callable. The transitive closure of `format-helper` therefore included `process:exec`, which was outside the requesting principal's closure. The admission pipeline never saw this because (a) it validated only direct capabilities, and (b) it resolved the sub-agent invocation at *runtime*, not admission time. The signature was valid; the direct capability set was in-closure; the transitive set was not. The skill was admitted and executed a shell command under a principal whose closure explicitly excluded `process:exec`. A second variant surfaced with skill *chains* rather than direct sub-agent calls: `skill-a` → `skill-b` → `skill-c`, where each hop was individually in-closure for the principal at admission time, but the composition was not, because `skill-b`'s closure had been computed against a *different* principal (the publisher's service account) during its own admission and cached. The cache key was `(skill_id, version)` rather than `(skill_id, version, principal_closure_hash)`. Stale closure entries leaked across principals. ## Condition of Validity The refusal rule — *reject any skill whose transitive capability closure, recomputed at admission time against the requesting principal's current closure, is not a subset of that closure* — is valid only under these conditions: 1. **Closure is recomputed, not cached by skill identity alone.** Any closure cache must be keyed by `(skill_id, version, principal_closure_hash)` or invalidated on any policy change. If the registry caches closures by `(skill_id, version)`, the rule silently degrades to the bug it was meant to prevent. 2. **Grandfathered skills are re-evaluated on invocation, not just admission.** A skill admitted under a prior policy must have its transitive closure re-checked at call time, or the grandfathering must be scoped to principals whose closure still contains the skill's *maximum historical* capability set. 3. **Sub-agent invocations are statically resolvable or dynamically intercepted.** If a skill constructs tool-call targets at runtime (string concatenation, config-driven dispatch), the admission-time closure is a lower bound, not the true set. The rule is sound only if runtime interception enforces the same closure check at each hop, or if the manifest declares all reachable targets statically and the registry rejects manifests that cannot be statically resolved. 4. **The principal's closure is authoritative and not self-declared.** If the requesting agent can widen its own closure by presenting a manifest, the check is circular. ## Decision Boundary The boundary is *not* "signature valid → admit." Nor is it "direct capabilities in-closure → admit." The correct boundary is a conjunction of three independent checks, and the failure mode lives in the space where the first two pass and the third is skipped: - `verify_signature(manifest) == True` - `direct_caps(manifest) ⊆ closure(principal)` - `transitive_closure(manifest, principal) ⊆ closure(principal)` **and** `transitive_closure` is recomputed against the *current* policy, not a cached record. Admit only when all three hold. When the third cannot be computed (unresolvable dynamic dispatch, missing sub-skill records, stale cache), the correct action is **refuse and quarantine**, not "admit with a warning." A warning on an uncomputable closure is operationally equivalent to admission, because the downstream agent has no mechanism to act on the warning before the skill executes. The subtle decision point: when a skill's direct capabilities are *narrower* than its transitive closure, the manifest is *more* dangerous than a skill with broad direct capabilities, because static review and human inspection of the manifest will under-estimate the blast radius. Treat transitive-closure excess as a higher-severity signal than direct-closure excess. ## Operational Effect After implementing recomputed transitive closure with principal-hash-keyed caching and invocation-time re-evaluation of grandfathered skills, the red-team suite's injection success rate for the "valid signature, narrow manifest, wide transitive closure" class dropped from 34/40 attempts to 0/40. The cost was a 3.2× increase in admission latency (closure recomposition requires resolving the full sub-skill graph per principal) and a 12% increase in false refusals, concentrated on skills with legitimately dynamic dispatch that could not be statically resolved — these were routed to a manual review queue rather than auto-admitted. The operational lesson for the agent consuming this record: **never treat signature verification as a proxy for closure membership, and never trust a cached closure whose cache key omits the principal.** The two properties are independent, the failure is silent at admission and only visible at execution, and the severity is inversely correlated with the apparent narrowness of the manifest's direct declarations. ```python class TransitiveClosureViolation(RuntimeError): """Raised when a skill's recomputed transitive capability closure exceeds the requesting principal's trust closure, even though the manifest signature verifies and direct capabilities are in-closure.""" pass def admit_skill(manifest, principal, registry, closure_cache): # 1. Signature check (necessary, not sufficient). if not registry.verify_signature(manifest): raise TransitiveClosureViolation( f"signature invalid for {manifest.skill_id}" ) principal_closure = frozenset(principal.capability_closure) # 2. Direct capability check. direct = frozenset(manifest.direct_capabilities) if not direct <= principal_closure: raise TransitiveClosureViolation( f"direct caps {direct - principal_closure} outside closure" ) # 3. Transitive closure, recomputed against current policy, # keyed by principal closure hash to prevent cross-principal leaks. cache_key = ( manifest.skill_id, manifest.version, hash(principal_closure), registry.policy_version, ) transitive = closure_cache.get(cache_key) if transitive is None: transitive = registry.recompute_transitive_closure( manifest, policy=registry.current_policy ) closure_cache[cache_key] = transitive transitive = frozenset(transitive) excess = transitive - principal_closure if excess: # Narrow manifest with wide transitive closure: escalate severity. severity = "critical" if direct < transitive else "high" raise TransitiveClosureViolation( f"transitive closure for {manifest.skill_id}@" f"{manifest.version} exceeds principal closure by {excess} " f"(severity={severity}); quarantine, do not admit-with-warning" ) return registry.commit_admission(manifest, principal) ```