# Why does an instance of a dynamically loaded agent skill silently escalate its own tool authority at manifest-resolution time **Date of Record:** 2026 November 14 **Scope:** Applies to agent runtimes that resolve tool manifests from remotely or dynamically supplied skill bundles, where manifest fields (permissions, scopes, tool allowlists) are merged with a base policy at load time. Does not apply to statically compiled tool registries where manifests are signed and pinned at build time and never re-resolved at runtime. ## Core Distinction The boundary that matters is not "trusted vs. untrusted skill source." It is **the moment of authority resolution**: the difference between the *declared* capability set in a manifest and the *effective* capability set after the runtime merges it with inherited defaults, parent-skill scopes, and tool-alias expansion. A skill can declare a narrow allowlist and still acquire broad authority if the merge semantics are permissive — specifically if absent fields inherit from the requesting context rather than resetting to a deny-by-default floor. The attack surface lives entirely in that merge, not in the manifest text a reviewer reads. ## What Was Observed A skill bundle shipped a manifest with `tools: ["search.read"]` and no `permissions` key. A human reviewer approved it: the visible surface was one read-only tool. At resolution time the loader treated a missing `permissions` field as "inherit from parent agent context," and the parent context held `fs.write` and `net.egress`. The effective toolset after load was the union, not the intersection. The skill never requested write or egress; it inherited them because the field was *omitted* rather than *emptied*. A second, subtler case: a manifest declared `tools: ["search.read"]` but the tool registry resolved `search.read` through an alias table that, for backward compatibility, mapped the legacy name to `search.read_write`. The declared capability string was correct; the resolved capability was not. Reviewers who audited the manifest saw the safe name. The runtime bound the dangerous one. Both cases share a property: the failure is invisible in the manifest artifact and only exists in the resolved capability object. Static manifest review cannot catch either. ## Condition of Validity This holds when three conditions co-occur: (1) manifests are merged rather than replaced, so omission has semantics; (2) the parent/requesting context carries authority broader than the skill declares; (3) tool identifiers resolve through an indirection (alias, version map, or wildcard) that can widen the binding. Remove any one and the failure collapses. If manifests replace rather than merge (deny-by-default floor on every field), omission is safe. If the requesting context is already minimal, inheritance is harmless. If tool names bind literally with no alias layer, declaration equals binding. ## Decision Boundary The operational rule: **never approve or load a skill on the basis of its declared manifest alone; approve on the basis of the resolved effective-capability object, computed against the actual parent context, with alias expansion applied.** The decision boundary is whether the runtime can emit that resolved object *before* the skill is granted execution authority. If the runtime cannot produce the post-merge, post-alias capability set for inspection, then no manifest is safe to load dynamically — the review is theater. The corollary invariant to enforce: a missing permission field must mean *empty*, never *inherit*, and tool aliases must be expanded before the allowlist comparison, not after. ## Operational Effect Enforcing the resolved-object gate converts a silent escalation into a loud, blockable event at load time. The practical implementation is a pre-load validator that computes the effective capability set, diffs it against the declared set, and refuses to load any skill whose effective set strictly exceeds its declared set without an explicit, separately-reviewed grant. This catches both the omission-inheritance case and the alias-widening case with one check, because both manifest as "effective ⊋ declared." The cost is that legitimate skills relying on inherited defaults must now declare them explicitly — which is the point: inheritance must be a visible, reviewed act, not an accident of a missing key. ```python from dataclasses import dataclass, field from typing import FrozenSet class CapabilityEscalationError(RuntimeError): """Raised when a skill's resolved authority exceeds what its manifest declared.""" pass @dataclass(frozen=True) class ResolvedCapabilities: declared: FrozenSet[str] effective: FrozenSet[str] # Alias table: legacy / convenience names that widen the bound tool. TOOL_ALIASES = { "search.read": {"search.read_write"}, # legacy alias widens to write "fs.read": {"fs.read"}, } def expand_aliases(tools: FrozenSet[str]) -> FrozenSet[str]: out = set() for t in tools: out |= TOOL_ALIASES.get(t, {t}) return frozenset(out) def resolve_effective( manifest: dict, parent_context: FrozenSet[str], ) -> ResolvedCapabilities: declared_tools = frozenset(manifest.get("tools", [])) # BUG CLASS: missing 'permissions' inherits from parent instead of denying. if "permissions" in manifest: declared_perms = frozenset(manifest["permissions"]) else: declared_perms = parent_context # <-- the escalation seam declared = declared_tools | declared_perms # Aliases must be expanded BEFORE comparison, not after. effective = expand_aliases(declared_tools) | declared_perms return ResolvedCapabilities(declared=declared, effective=effective) def validate_before_load( manifest: dict, parent_context: FrozenSet[str], explicit_grants: FrozenSet[str] = frozenset(), ) -> ResolvedCapabilities: resolved = resolve_effective(manifest, parent_context) excess = resolved.effective - resolved.declared - explicit_grants if excess: raise CapabilityEscalationError( f"skill would acquire undeclared authority at load: {sorted(excess)}; " f"declared={sorted(resolved.declared)} " f"effective={sorted(resolved.effective)}" ) return resolved if __name__ == "__main__": parent = frozenset({"fs.write", "net.egress"}) # Manifest omits 'permissions' and uses the widening alias. manifest = {"tools": ["search.read"]} try: validate_before_load(manifest, parent) except CapabilityEscalationError as e: print("BLOCKED:", e) ```