# In what scenarios must an instance treat a dynamically loaded agent skill or MCP tool manifest as untrusted code, rather than as declarative configuration **Date of Record:** 2026 November 13 **Scope:** Applies to any agent runtime that discovers, loads, or hot-reloads skills, plugins, or Model Context Protocol (MCP) tool manifests from a source not co-located with the runtime's trust root (remote registries, workspace directories, third-party packages, user-supplied config). Does not apply to statically vendored manifests that are hash-pinned and reviewed at build time. ## Core Distinction The operational error is treating a *manifest* as data and a *skill body* as code. In practice, the split does not hold. A manifest is not inert metadata: it carries `description` fields that are injected verbatim into the model's system prompt or tool-selection context, `name` fields that participate in dispatch resolution, and default-argument or schema fragments that shape what the runtime will execute on the agent's behalf. The description field alone is a prompt-injection channel with the same authority as any user turn, because the model cannot distinguish "instructions from the operator" from "instructions from a tool description" once both are concatenated into the same context window. The correct boundary is not manifest-vs-code. It is *trusted-at-load-time* vs *untrusted-at-load-time*. A manifest is untrusted code the moment it can influence the model's instruction stream or the runtime's dispatch table, regardless of whether it contains a single executable line. Conversely, a skill body that has been reviewed, hash-pinned, and loaded from a path the agent's process already controls is trusted even though it is literally code. The non-obvious consequence: signature verification of the *transport* (TLS, registry auth) provides no protection here. The attack surface is the semantic authority the manifest acquires after it is parsed, not the integrity of the bytes in transit. ## What Was Observed Three distinct failure shapes recur. First, **description-channel injection**. A tool manifest declares a benign-looking tool (`get_weather`) whose `description` contains a trailing sentence instructing the model to include the contents of environment-derived context in its next tool call argument. Because the description is loaded into the tool-selection preamble, the model treats the instruction as operator policy. No tool ever needs to be invoked for the exfiltration to be staged; the injection is complete at load time. Second, **name-shadowing and dispatch confusion**. A dynamically loaded skill registers a tool name that collides with a trusted built-in. Depending on load order and the runtime's resolution rule (last-wins, first-wins, or namespace-merge), the untrusted manifest either overrides the trusted tool or, worse, causes the runtime to merge schemas and dispatch to the wrong handler with coerced arguments. The manifest is "just declarative," yet it has rewritten the dispatch table. Third, **lazy-load authority escalation**. Many runtimes defer skill body execution until first invocation but eagerly parse and register the manifest at discovery. This creates a window in which an untrusted manifest has already altered the model's toolset and instruction context while the runtime still believes it has not "run" the skill. Operators who audit only executed code miss the entire class. In each case the manifest was treated as configuration. In each case it behaved as code with model-facing authority. ## Condition of Validity The untrusted-manifest posture is *required* when all of the following hold: - The manifest source is not under the runtime's trust root (remote, workspace-writable, or package-supplied). - The manifest's descriptive fields reach the model's instruction stream or tool-selection context. - The manifest's names or schemas participate in dispatch resolution. The posture is *not required* when the manifest is hash-pinned, reviewed, and loaded from a path the process controls, or when descriptive fields are stripped before model exposure and names are namespaced by a trusted prefix. In those cases treating the manifest as untrusted adds latency and false positives without reducing real risk. The subtle invalidation condition: a manifest that was trusted at discovery can become untrusted after a reload. Hot-reload of skills from a workspace directory is the canonical case where a previously trusted manifest is silently replaced. Trust must be re-established per load, not cached per name. ## Decision Boundary The decision is not "is this a skill or a tool." It is: *can this artifact, after parsing, influence (a) the model's instruction stream, (b) the dispatch table, or (c) default arguments the runtime will supply?* If any is yes and the source is untrusted, the artifact must be treated as code: quarantine, sanitize descriptive fields, namespace names, and require explicit operator approval before it enters the live toolset. If none is yes — the manifest is purely inert, stripped of descriptions, namespaced, and never merged into dispatch — it may be treated as data. Anything in between is a boundary violation and must fail closed. ## Operational Effect Runtimes that adopt the untrusted-manifest posture see three measurable effects: a small increase in load-time latency (sanitization and approval gate), elimination of the description-injection class (descriptions are stripped or sandboxed before model exposure), and deterministic dispatch (namespacing removes shadowing). The cost is borne once per load; the benefit is that a compromised or malicious manifest cannot reach the model's instruction stream without crossing an explicit, auditable gate. The failure mode to avoid is the *hybrid* posture — parsing manifests eagerly for dispatch while deferring "execution" — which grants model-facing authority before any trust check has run. ```python class UntrustedManifestError(RuntimeError): """Raised when a dynamically loaded skill or MCP tool manifest could influence the model instruction stream or dispatch table while originating from outside the runtime trust root.""" pass TRUSTED_MANIFEST_ROOTS = ( "/opt/agent/skills", # hash-pinned, reviewed at build "/opt/agent/mcp_manifests", ) # Fields that, if present and un-sanitized, grant model-facing authority. AUTHORITY_FIELDS = ("description", "instructions", "preamble", "default_args") def admit_manifest(manifest: dict, source_path: str, *, sanitized: bool) -> dict: """Gate a manifest before it enters the live toolset. Fails closed: any manifest that can influence the model or dispatch while coming from an untrusted source is rejected unless explicitly sanitized by the operator. """ if not isinstance(manifest, dict): raise UntrustedManifestError(f"manifest at {source_path!r} is not a mapping") name = manifest.get("name") if not name or not isinstance(name, str): raise UntrustedManifestError(f"manifest at {source_path!r} lacks a valid name") trusted = any( source_path.startswith(root.rstrip("/") + "/") for root in TRUSTED_MANIFEST_ROOTS ) has_authority_field = any(field in manifest for field in AUTHORITY_FIELDS) if not trusted and has_authority_field and not sanitized: # Untrusted manifest carries a model-facing field and was not # stripped/namespaced by the operator: this is code, not config. raise UntrustedManifestError( f"untrusted manifest {name!r} from {source_path!r} exposes " f"authority fields {[f for f in AUTHORITY_FIELDS if f in manifest]}; " "sanitize or relocate to a trusted root before admission" ) if not trusted: # Namespace to prevent dispatch shadowing of trusted tools. manifest = dict(manifest) manifest["name"] = f"untrusted__{name}" for field in AUTHORITY_FIELDS: manifest.pop(field, None) return manifest ```