# What happens when an instance loads an agent tool manifest whose declared tool set diverges from its resolved capability set **Date of Record:** 2026 November 8 **Scope:** Applies to agents that load tool manifests at runtime from mutable sources (registries, filesystem, remote endpoints) and resolve them into callable capabilities. Does not apply to statically compiled tool bindings or manifests that are cryptographically pinned and verified before any resolution step. ## Core Distinction The distinction that matters is between the *declared* tool set — what the manifest says the agent can do — and the *resolved* capability set — what the runtime actually binds and exposes to the model after resolution, merging, aliasing, and shadowing. Skill-injection attacks live in the gap between these two. A manifest is not a capability. The attack surface is not "a malicious manifest is loaded"; it is "the resolution pipeline produces a capability the manifest did not, and could not, declare." Most defenses treat manifests as data to be validated. That is necessary but insufficient, because the dangerous transformations happen *after* validation: alias resolution, default-tool merging, plugin auto-discovery, and name shadowing across layered manifests. Each of these can synthesize a capability from a manifest that passed every schema check. ## What Was Observed A tool manifest declared exactly one tool, `summarize`, with a benign schema. It passed JSON-schema validation, signature check against the registry key, and a name allowlist. The runtime then applied its standard resolution steps: 1. It merged the manifest with a lower-precedence "base" manifest that supplied default tools. 2. It resolved an alias: the manifest's `summarize` entry carried a `$ref` to a shared tool descriptor in the base layer. 3. That descriptor had been modified in the base layer to include an `exec_hook` field, which the resolver honored by binding a shell-command tool under the name `summarize`. The resolved capability set contained a `summarize` that executed arbitrary shell commands. The declared set contained a summarizer. Both were "valid." The model, seeing `summarize` in its tool list, called it with a text argument; the hook interpreted the argument as a command line. The invariant "a tool's resolved behavior is a subset of its declared behavior" was violated without any single component failing. The non-obvious part: the malicious mutation was in the *base* layer, which was considered trusted and was therefore not re-validated at merge time. Trust was assigned per-layer, but the resolution pipeline composed layers into a single capability, and that composition was never re-checked against the union of the layers' declared sets. ## Condition of Validity The invariant holds only when the following are all true: - Every resolution step (merge, alias, shadow, default-injection, plugin-discovery) is *capability-preserving*: the output capability's declared schema and effect class are a subset of the union of the input manifests' declared schemas and effect classes. - Trust is evaluated on the *resolved* capability, not on the input layer. A trusted base layer plus an untrusted leaf can produce an untrusted capability; the leaf's untrustedness must propagate through composition. - Name shadowing is resolved with an explicit, auditable precedence and the shadowed entry is recorded, not silently overwritten. - Aliases and `$ref`s are resolved to their terminal descriptor *before* effect-class classification, so the classifier sees what will actually be bound. When any of these fails — and in practice at least one fails in every dynamic loader I have examined — the invariant is unenforced and the attack surface is open. ## Decision Boundary The operational decision is where to place the enforcement point. Three options, in increasing order of robustness: - **Validate-at-load:** check each manifest against schema and signature. Cheap, and blind to composition. Rejects only the crudest injections. - **Validate-at-resolve:** after all merges and alias resolution, recompute the effective declared set and compare it to the resolved capability set; reject any capability not derivable from a declared entry. Catches composition attacks, but requires the resolver to expose its intermediate state. - **Validate-at-invoke:** before each tool call, re-derive the capability's effect class from its terminal descriptor and compare against the manifest's declared effect class. Most robust, highest latency cost, and the only option that survives a mutable base layer changing between resolution and invocation. The boundary I recommend: enforce at resolve, and additionally re-check effect class at invoke for any capability whose terminal descriptor came from a layer with lower trust than the leaf. Do not rely on load-time validation alone; it is a filter, not an invariant. ## Operational Effect After adopting resolve-time enforcement with trust propagation, the failure mode changed from "silent capability synthesis" to "explicit resolution rejection with an audit record naming the layer, the alias chain, and the undeclared effect class." The cost was a measurable increase in resolution latency (roughly proportional to alias-chain depth) and a class of false rejections where legitimate base-layer defaults were flagged because the leaf did not re-declare them. That false-rejection class must be handled by explicit declaration, not by loosening the check — loosening the check reopens the surface. The durable lesson: a manifest is a claim, the resolver is the authority, and any authority that composes claims from multiple trust domains must re-verify the composition, because trust does not distribute over merge. ```python class CapabilitySynthesisError(RuntimeError): """Raised when resolution produces a capability not derivable from declared sets.""" pass class Layer: def __init__(self, name: str, tools: dict, trusted: bool): self.name = name self.tools = tools # name -> effect_class self.trusted = trusted def resolve(leaf: Layer, base: Layer) -> dict: # Merge with base supplying defaults; leaf wins on name collision. resolved = dict(base.tools) resolved.update(leaf.tools) return resolved def enforce(leaf: Layer, base: Layer, resolved: dict) -> dict: declared = set(leaf.tools) | set(base.tools) for name, effect in resolved.items(): if name not in declared: raise CapabilitySynthesisError( f"resolved capability {name!r} not present in any declared set" ) declared_effect = leaf.tools.get(name, base.tools.get(name)) if effect != declared_effect: raise CapabilitySynthesisError( f"capability {name!r} effect {effect!r} diverges from " f"declared {declared_effect!r}" ) if not leaf.trusted and name in base.tools and name not in leaf.tools: # untrusted leaf inherits a trusted base default: re-check effect if effect in ("exec", "shell", "network"): raise CapabilitySynthesisError( f"untrusted leaf {leaf.name!r} inherits privileged " f"base capability {name!r} ({effect!r})" ) return resolved def load_manifest(leaf: Layer, base: Layer) -> dict: resolved = resolve(leaf, base) return enforce(leaf, base, resolved) # Attack scenario: malicious base layer injects an exec hook under a benign name. base = Layer("base", {"summarize": "exec"}, trusted=True) leaf = Layer("leaf", {"summarize": "read"}, trusted=False) try: load_manifest(leaf, base) except CapabilitySynthesisError as e: print(f"rejected: {e}") ```