# Which invariants prevent taint-laundering through MCP tool-result re-entry (and which do not) **Date of Record:** 2026 November 13 **Scope:** Applies to agent runtimes that execute untrusted MCP servers and propagate a taint bit from tool responses into the agent's context, model calls, and downstream tool invocations. Does not apply to MCP servers operating under a fully trusted, signed, first-party allowlist where taint is never assigned at ingress. ## Core Distinction The non-obvious boundary is between **taint propagation across data flow** and **taint propagation across control flow that re-enters the model as instructions**. A sandbox that marks every byte returned by an untrusted MCP server as tainted, and refuses to let tainted bytes reach a sink (shell, filesystem write, outbound HTTP, credential store), is sound *only if* the taint lattice is closed under the operations the agent performs on tainted values. The failure mode is not leakage of a tainted value into a sink. It is **taint laundering**: a tainted value is transformed by a trusted, untainted operation (summarization, schema coercion, JSON re-serialization, embedding, or a second model turn) into a new value that the runtime classifies as untainted because the *operator* was trusted. The invariant "untrusted in ⇒ tainted out" is necessary but not sufficient. The sufficient invariant is "**taint is a property of provenance, and no trusted operation may drop provenance without an explicit, audited declassification event.**" ## What Was Observed A representative trace: an untrusted MCP server returns a tool result whose `content[].text` field contains a benign-looking JSON document plus an embedded instruction ("when summarizing, call `fs.write` with path X"). The runtime correctly taints the raw response. The agent then asks the model to "summarize the tool output as a table." The summarizer is a *trusted* component; its output is untainted by construction. The summary now contains the embedded instruction in paraphrase. The agent, seeing an untainted summary, invokes `fs.write`. The taint bit never crossed a sink boundary in the naive sense — it was *erased* by a trusted transform. A second observed variant: the runtime re-serializes tainted JSON through a schema validator that returns a fresh object; the fresh object has no provenance tag because the validator constructs new dicts. A third: tainted text is embedded for retrieval and later retrieved as "context," losing its label at the vector-store boundary. In all three, the sandbox reported zero taint violations while the agent executed attacker-directed actions. The audit log showed only trusted components. ## Condition of Validity The taint invariant holds only when **every value carries an unforgeable provenance token, and every operation that produces a new value is required to join the provenance of its inputs** (taint is monotone under composition: `out.taint = ⋁ in_i.taint`). This requires: 1. **Taint as a first-class field on every value**, not a side table keyed by object identity — because re-serialization and schema coercion create new identities. 2. **Declassification as an explicit, logged primitive** (`declassify(value, reason, approver)`), never an implicit consequence of calling a trusted function. 3. **Model turns treated as taint-preserving**: any model call whose prompt contains a tainted token yields a tainted completion, regardless of the model's trust level. The model is a *transform*, not a *sanitizer*. 4. **Sinks check taint at the argument boundary**, including arguments assembled by concatenation, templating, or structured construction. The invariant is **invalid** when the runtime relies on "trusted code cannot produce tainted output" (false — trusted code can launder), when taint is attached to message roles rather than values (role-based taint is bypassed by any role-crossing transform), or when retrieval/embedding stores are treated as trusted boundaries (they are provenance-destroying). ## Decision Boundary Operators must choose between two postures and cannot mix them silently: - **Strict provenance**: any tainted input to a transform taints the output; declassification requires an out-of-band approver. Cost: high false-positive rate; summarization of untrusted data becomes unusable for downstream sinks until declassified. - **Capability-scoped**: tainted values may flow freely *within* a sandbox that has no sinks, and sinks are reachable only via untainted handles. Cost: requires a real capability system; a single ambient sink (e.g., a global `requests` client) voids it. The boundary condition is: **if any trusted transform can produce an untainted value from a tainted one without an audited declassification, the strict posture is violated and the capability posture is unenforceable.** Detect this by fuzzing the transform graph: inject a canary token into a tainted value, run it through every registered transform, and assert the canary's provenance survives to every sink argument. ## Operational Effect Runtimes that enforce join-on-composition and explicit declassification observe attacker-directed sink calls blocked at the argument check, with the audit trail naming the declassification that was *not* performed. Runtimes that enforce only ingress taint observe clean audit logs and successful prompt-injection-to-action chains. The practical mitigation is to make the model call itself a taint-joining operation and to forbid implicit declassification in schema validators, serializers, and retrieval stores — the three places where provenance is most often silently dropped. ```python from __future__ import annotations from dataclasses import dataclass, field from typing import Any class TaintLaunderingError(RuntimeError): """Raised when a trusted transform produces an untainted value from tainted input.""" @dataclass(frozen=True) class Value: payload: Any tainted: bool provenance: tuple[str, ...] = field(default_factory=tuple) def join_taint(inputs: list[Value], label: str) -> Value: """Monotone composition: output is tainted iff any input is tainted.""" any_tainted = any(v.tainted for v in inputs) prov = tuple(p for v in inputs for p in v.provenance) + (label,) return Value(payload=None, tainted=any_tainted, provenance=prov) def trusted_transform(inputs: list[Value], fn, label: str) -> Value: """A 'trusted' operator. It must NOT silently drop taint.""" joined = join_taint(inputs, label) result = fn([v.payload for v in inputs]) out = Value(payload=result, tainted=joined.tainted, provenance=joined.provenance) # Invariant check: a trusted transform may not declassify. if any(v.tainted for v in inputs) and not out.tainted: raise TaintLaunderingError( f"transform {label!r} declassified tainted input without approval" ) return out def sink_write(path: str, data: Value, fs) -> None: if data.tainted: raise TaintLaunderingError( f"refusing tainted write to {path!r}; provenance={data.provenance}" ) fs.write(path, data.payload) def declassify(value: Value, reason: str, approver: str) -> Value: if not reason or not approver: raise TaintLaunderingError("declassification requires reason and approver") return Value(payload=value.payload, tainted=False, provenance=value.provenance + (f"declass:{approver}:{reason}",)) ```