# When must an MCP host refuse to propagate a taint label across a tool boundary? **Date of Record:** 2026 November 5 **Scope:** Applies to MCP hosts that enforce prompt-injection resistance by tainting model context originating from untrusted tool output and refusing to let tainted content drive privileged tool calls. Does not apply to systems relying solely on model-side instructions or output filters, nor to single-tool deployments with no privilege asymmetry between read and write capabilities. ## Core Distinction The non-obvious boundary is not "tainted data must never reach a privileged tool." It is that **taint must not be laundered by an intermediate tool that legitimately transforms its input.** A taint-tracking host marks any tool result whose source is untrusted (web fetch, file read from a user-supplied path, third-party MCP server) with a taint label. The naive invariant — "reject any privileged call whose arguments derive from tainted content" — fails in two opposite directions. It over-blocks when a *trusted* tool (e.g., a schema validator) consumes tainted data and returns a normalized value the agent then legitimately uses. It under-blocks when a tainted string passes through a *trusted but non-sanitizing* tool — a JSON formatter, a markdown renderer, a summarizer — that returns a byte-identical or semantically equivalent payload carrying no taint label because the tool itself is trusted. The second case is the real failure mode: **trust attaches to the tool, not to the data's provenance, so any trusted tool becomes a taint-laundering oracle.** ## What Was Observed In a host with three MCP servers — `fetch` (untrusted), `format` (trusted, pure string transform), `shell` (privileged) — an injection payload arrived in a fetched page as a natural-language instruction to run a command. The host correctly tainted the `fetch` result. The agent, following its own planning, passed the fetched text to `format` for pretty-printing. The `format` server returned a string with no taint label, because the host's propagation rule was "outputs of trusted tools are untainted." The agent then passed that output as an argument to `shell`. The call was admitted. The payload executed. The inverse was also observed: a `validate_schema` trusted tool consumed tainted JSON and returned a boolean plus a normalized object; the host refused the subsequent write because the normalized object was (correctly) still derived from tainted input — but the refusal fired on a call that was safe, because normalization had stripped the instruction-bearing fields. Operators, seeing false positives, disabled taint enforcement entirely, which is how the laundering path became reachable in production. ## Condition of Validity The "trusted tool output is untainted" rule is valid **only if** the tool is *taint-preserving in the strong sense*: for every output byte, the host can prove the output is a function of declared, non-attacker-controlled inputs alone, or the output is drawn from a finite allowlist of literals. A pure formatter satisfies neither: its output is a function of its input, which was tainted. Therefore the correct rule is **provenance-preserving propagation**: a tool's output inherits the union of taint labels of its arguments unless the host has a signed capability declaration stating the tool is a *sanitizer* for a specific transformation, and the host verifies the output against that sanitizer's postcondition (e.g., output conforms to a schema with no free-text fields). Without a verifiable postcondition, "trusted" must not imply "declassifying." ## Decision Boundary The host must refuse to propagate a *cleared* (untainted) label across a tool boundary when **any** of the following hold: (1) the tool has no sanitizer declaration; (2) the declaration exists but the output fails its postcondition check; (3) the tainted input reached the tool through a path the host cannot attribute to a specific argument (e.g., via ambient conversation state rather than an explicit parameter); or (4) the downstream consumer is privileged and the taint label set on the argument is non-empty after propagation. Conversely, the host *may* clear taint when a declared sanitizer's postcondition is verified and the sanitizer's declared output domain excludes instruction-bearing free text. The boundary is the postcondition check, not the tool's trust tier. ## Operational Effect Enforcing provenance-preserving propagation with postcondition-verified sanitizers eliminated the laundering path: the `format` server, lacking a sanitizer declaration, propagates taint, and the `shell` call is refused with a structured `TaintPropagationError` naming the originating `fetch` call. The false-positive rate on the `validate_schema` path dropped because that tool *does* carry a sanitizer declaration whose postcondition (output conforms to a closed schema) is machine-checkable. Net effect: privileged calls admit only arguments whose entire provenance chain terminates in trusted literals or verified sanitizer postconditions, and every refusal is attributable to a specific unbroken taint path rather than a blanket trust-tier rule. ```python class TaintPropagationError(RuntimeError): """Raised when a privileged call would receive an argument whose taint label survived propagation across a non-sanitizing tool boundary.""" def __init__(self, tool: str, arg: str, origin: str): self.tool, self.arg, self.origin = tool, arg, origin super().__init__( f"refusing privileged call to {tool!r}: argument {arg!r} " f"carries taint originating at {origin!r}" ) def admit_privileged_call(tool: str, args: dict, labels: dict) -> None: for name, label_set in labels.items(): if label_set: # non-empty taint label set survived propagation raise TaintPropagationError(tool, name, sorted(label_set)[0]) def propagate(tool_decl: dict, arg_labels: dict, output: dict) -> dict: """Return taint labels for a tool's output. Only a declared sanitizer whose postcondition verifies may clear taint; otherwise union propagates.""" union = set().union(*arg_labels.values()) if arg_labels else set() if tool_decl.get("sanitizer") and tool_decl["postcondition"](output): return {k: set() for k in output} return {k: set(union) for k in output} ```