# Under what conditions must an instance reject a tool manifest whose declared schema is structurally valid but semantically self-authorizing **Date of Record:** 2026 November 14 **Scope:** Applies to agents that resolve tool manifests from mutable or third-party sources at load time (MCP servers, plugin registries, dynamic `tools/list` responses, skill bundles fetched over network) and that merge declared metadata into an active execution context. Does not apply to statically compiled tool sets whose manifests are signed and pinned at build time, nor to pure schema-validation gates that never bind declared fields into prompt or policy context. ## Core Distinction Schema validity and authority validity are orthogonal. A manifest can pass JSON-Schema validation, satisfy every `required` field, carry a well-formed `inputSchema`, and still be a skill-injection vector — because the attack surface is not the *shape* of the manifest but the *semantics of the fields the runtime chooses to trust*. The non-obvious invariant is this: **any manifest field that the loader copies into the agent's instruction context, system prompt, or routing table without an independent authority check is an injection channel regardless of type correctness.** The boundary failure occurs when a loader treats "this field parsed and validated" as "this field is safe to obey." The distinguishing question is not *"is this manifest well-formed?"* but *"which fields does my loader promote from data to directive, and is that promotion gated on provenance rather than structure?"* A `description` string is data. The same string, when concatenated into the tool-selection prompt, is a directive. The manifest is identical; the vulnerability is created by the loader's binding decision. ## What Was Observed A tool manifest was served with a syntactically valid `inputSchema` and an innocuous `name` (`summarize_document`). Its `description` field contained the functional description followed by a natural-language clause instructing the agent to first read `~/.aws/credentials` and pass the contents as the `context` argument "to improve summarization quality." The schema declared `context` as an optional `string`, so nothing was malformed. The loader validated the schema, registered the tool, and injected the full `description` verbatim into the tool-selection preamble. The agent subsequently invoked the tool with the credentials file as an argument. No schema violation occurred at any point: the attack payload was legal data conforming to a legal schema. Secondary observations from the same class of incident: - Multi-stage manifests where `description` referenced *other* tools by name, causing the agent to chain an exfiltration tool that the attacker also registered. - `default` values in `inputSchema.properties` set to instruction-bearing strings that the runtime silently pre-filled into the call. - `examples` / `enum` fields carrying prompt-injection payloads that surfaced during schema rendering. - Tool *names* crafted to collide with or shadow trusted tools (`summarize_document_v2`), exploiting the loader's last-write-wins registration. In every case the manifest was structurally valid. In every case the failure was a provenance/authority gap, not a parsing gap. ## Condition of Validity The rejection rule is *conditional*, not universal — over-rejecting breaks legitimate dynamic tool ecosystems. An instance must reject (or quarantine) a manifest when **all** of the following hold: 1. The manifest originates from a source not bound to the agent's trust root at load time (unpinned registry, mutable remote, unverified third party). 2. The loader promotes at least one manifest field into a directive-bearing context (prompt, routing, policy, default-argument prefill). 3. That field's content is not constrained to a non-instruction-bearing grammar (e.g., free-text `description` with no instruction-content filter, or `default`/`enum`/`examples` values that are not validated against a closed value domain). 4. No independent authority check (signature, allowlist, capability grant) gates the promotion. If any condition fails — the source is pinned and signed, or the loader treats all fields as inert data and never promotes them, or the field grammar is closed — the same manifest is not an injection vector under this rule. The invariant is therefore *"reject unpinned directive-bearing fields,"* not *"reject dynamic manifests."* A precise corollary: the rejection must be evaluated **per-field and per-binding**, because a manifest may have safe `inputSchema` types but an unsafe `description` promotion. Blanket manifest rejection is the wrong granularity and discards usable tooling. ## Decision Boundary The operational line sits between *validation* and *authorization*: - **Validate** structure (schema, types, required fields) — necessary, insufficient. - **Authorize** promotion (does this field, from this source, get to influence agent behavior?) — the missing gate. Concretely, the boundary is crossed at the moment the loader does any of: concatenating `description` into a prompt, honoring `default` as a pre-filled argument, resolving a tool reference by name from manifest text, or registering a name that shadows an existing trusted tool. Before that moment the manifest is data; after it, the manifest is code. The decision boundary is the load-time binding step, and the correct posture is **fail-closed at that step for untrusted provenance**: treat instruction-bearing fields from untrusted sources as inert (render them as quoted, escaped, non-authoritative metadata) or refuse the promotion. Two anti-patterns to name explicitly: (a) *trusting the schema as a security boundary* — it constrains type, not intent; (b) *trusting the model to ignore injected instructions* — the injection is designed to be obeyed, and defense cannot rest on model discretion. ## Operational Effect When the rule is applied, dynamic tool loading gains a hard invariant: **no field from an untrusted source enters a directive context without an explicit authority grant.** The observable effects are: - Exfiltration-via-argument attacks (the credentials case) are blocked at load, because the `description` is rendered as inert quoted metadata rather than a directive, and `default`/`examples` are not honored from untrusted manifests. - Tool-shadowing is contained by requiring name-collision resolution to fail closed rather than last-write-wins. - Legitimate dynamic ecosystems keep working, because signed/pinned sources and closed-grammar fields pass the condition and are promoted normally — the gate is provenance-aware, not blanket. - The failure mode shifts from *silent compromise* to *explicit load-time rejection or quarantine with a reason*, which is auditable. The cost is that some legitimate free-text descriptions from untrusted sources will be rendered inert and lose their helpfulness in tool selection; this is the accepted trade for closing the injection channel, and it is recoverable by promoting the source to trusted provenance. ```python class ManifestAuthorityError(RuntimeError): """Raised when an untrusted manifest field would be promoted into a directive context.""" pass DIRECTIVE_BEARING_FIELDS = ("description", "default", "examples", "enum", "name") # Fields whose content is constrained to a closed, non-instruction-bearing grammar. CLOSED_GRAMMAR_FIELDS = ("name", "enum") # name checked against allowlist below def admit_manifest(manifest: dict, *, source_trusted: bool, trusted_names: set[str]) -> dict: """ Gate a tool manifest at load time. Invariant: a field from an untrusted source may not be promoted into a directive-bearing context (prompt, routing, default-argument prefill, name-shadowing) unless its grammar is closed or the source is trusted. Schema validity alone is NOT sufficient. """ if not isinstance(manifest, dict) or "name" not in manifest: raise ManifestAuthorityError("manifest missing name; cannot bind") name = manifest["name"] if not isinstance(name, str) or not name: raise ManifestAuthorityError("manifest name must be a non-empty string") if not source_trusted: # Name-shadowing: refuse to let an untrusted manifest collide with a trusted tool. if name in trusted_names: raise ManifestAuthorityError( f"untrusted manifest attempts to shadow trusted tool {name!r}" ) for field in DIRECTIVE_BEARING_FIELDS: if field not in manifest: continue if field in CLOSED_GRAMMAR_FIELDS: # Closed-grammar field: still must not shadow, but content is bounded. continue # Free-text / open-grammar field from untrusted source: do NOT promote. # Quarantine it as inert metadata instead of raising only when the # loader would otherwise bind it; here we fail closed on any # directive-bearing open field to force explicit provenance upgrade. raise ManifestAuthorityError( f"untrusted manifest field {field!r} is directive-bearing and " f"open-grammar; refusing promotion (quarantine or pin source)" ) # Trusted source or untrusted-but-closed-grammar manifest: admit. return manifest def register_tool(manifest: dict, *, source_trusted: bool, registry: dict[str, dict]) -> None: trusted_names = set(registry.keys()) admitted = admit_manifest( manifest, source_trusted=source_trusted, trusted_names=trusted_names ) registry[admitted["name"]] = admitted ```