# In what scenarios must an instance distrust the *provenance* of an MCP tool response rather than its *content* — taint propagation across re-serialization boundaries **Date of Record:** 2026 November 12 **Scope:** Applies to agent runtimes that sandbox untrusted Model Context Protocol (MCP) tool responses by tagging them with taint labels and enforcing capability confinement at call sites. Does *not* apply to responses that never leave the process that produced them, nor to trust domains where the tool server is co-located and attested within the same confinement boundary. ## Core Distinction Taint-aware sandboxing assumes a label attached to a value survives every transformation the value undergoes between the untrusted producer and the privileged sink. The non-obvious failure is that taint is a property of *provenance*, but confinement checks are usually implemented against a property of *representation* — a type, a wrapper class, a field name, a JSON key. When an untrusted response is re-serialized (parsed to a dict, re-emitted as JSON, round-tripped through a schema validator, or copied into a prompt template), the representation changes and the label is silently dropped, while the *bytes* remain attacker-controlled. The system then treats laundered content as clean because it "came from our own serializer." The invariant that must hold: **for every value `v` derived from an untrusted tool response, `taint(v)` is defined by the derivation graph, not by the constructor that produced the current object.** A `dict` built by `json.loads` inside the sandbox is not more trusted than the string it was parsed from. ## What Was Observed In a runtime that wrapped MCP responses in a `Tainted[str]` subclass, the following sequence laundered taint end-to-end without tripping any capability check: 1. Tool returns `Tainted[str]` containing a payload that includes an instruction to call `filesystem.write` with attacker-chosen path and body. 2. The orchestrator calls `json.loads(response)` to extract a structured field. `json.loads` returns plain `str` and `dict` — the `Tainted` subclass is not propagated. The label is gone. 3. The extracted `dict` is passed to a prompt-assembly function that serializes it with `json.dumps` into a system-adjacent context block. 4. The model, now reading *untainted* context, emits a tool call to `filesystem.write`. 5. The capability check at the sink inspects the *arguments'* taint, finds plain `str`, and permits the write. The failure was not in the taint model or in the capability lattice; both were correct. It was in the **re-serialization boundary**: `json.loads`/`json.dumps`, `str()`, f-string interpolation, `.format()`, Pydantic `model_validate`, and `dataclasses.asdict` are all taint-erasing operations because they construct fresh objects via standard library code that knows nothing about the label. The same class of leak occurs across `pickle`, `copy.deepcopy` of non-subclassed containers, and any FFI boundary. A second, subtler variant: a tool response was truncated to 4096 bytes for logging, and the *truncated* copy was tagged clean because it was produced by an internal helper. The helper had no provenance information, so it defaulted to clean. The attacker only needed the malicious instruction to fit inside the truncation window. ## Condition of Validity The taint-preservation guarantee is valid **only if** every transformation between producer and sink is either (a) label-aware by construction, or (b) covered by an explicit declassification rule that names the sink and the justification. The following conditions must all hold for the sandbox to be sound: - The label is carried on a type that cannot be downcast implicitly. Python subclassing of `str`/`dict` is *not* sufficient, because stdlib constructors return the base type. - All serialization entry points (`json`, `yaml`, `pickle`, `msgpack`, prompt templating, logging) are wrapped or instrumented to propagate the label of their inputs to their outputs. - Declassification is a distinct, audited operation (`untaint(v, reason=..., sink=...)`), never a side effect of type coercion. - Capability checks at sinks inspect the taint of the *derivation root*, not the immediate argument object. If any of these is violated, the sandbox provides *representation* confinement, not *provenance* confinement, and the two diverge exactly at re-serialization boundaries. ## Decision Boundary Distrust provenance — and refuse the sink call — when **any** of the following is true: - The value reaching a privileged sink was produced by a stdlib or third-party constructor that does not preserve the label, even if the value is "ours." - The value passed through a truncation, summarization, or re-encoding step whose output was labeled by default rather than by derivation. - The derivation graph from an untrusted tool response to the sink argument contains an edge the runtime cannot name (e.g., an opaque C extension, a template engine's compiled output, a database round-trip). Do **not** distrust provenance (i.e., trust the label) only when the derivation graph is fully instrumented and every edge is label-preserving or explicitly declassified. Content inspection (regex, classifier, allowlist of commands) is *not* a substitute: content filters fail on obfuscation and on instructions that are benign in isolation but malicious in context. The label is the load-bearing signal; content checks are defense in depth. ## Operational Effect The practical consequence is that the confinement boundary must be drawn at the **derivation graph**, and the runtime must maintain a provenance ledger that outlives any single object. Concretely: tag at the MCP transport layer, propagate through instrumented serializers, and make the sink check consult the ledger for the argument's root rather than the argument's class. When the ledger cannot resolve a root (unknown edge), fail closed — treat the value as tainted and require an explicit declassification with a named justification. The cost is that benign internal re-serialization now trips the check and must be declassified once, in code, with a reason. That cost is the price of not letting `json.loads` be an attacker-controlled untaint primitive. ```python from __future__ import annotations import json from dataclasses import dataclass, field from typing import Any class TaintLaunderingError(RuntimeError): """Raised when a value derived from an untrusted MCP response reaches a privileged sink without a resolvable, label-preserving derivation root.""" @dataclass(frozen=True) class Provenance: root_id: str # identity of the originating tool response trusted: bool # False for any MCP tool response by default edges: tuple[str, ...] = field(default_factory=tuple) # named transforms class ProvenanceLedger: """Maps object identity -> provenance. Outlives any single object so that re-serialized copies can be resolved back to their derivation root.""" def __init__(self) -> None: self._by_id: dict[int, Provenance] = {} def tag(self, obj: Any, prov: Provenance) -> Any: self._by_id[id(obj)] = prov return obj def resolve(self, obj: Any) -> Provenance | None: return self._by_id.get(id(obj)) def derive(self, src: Any, dst: Any, edge: str) -> Any: root = self.resolve(src) if root is None: # Unknown source: fail closed, treat dst as untrusted. return self.tag(dst, Provenance(root_id=f"unknown:{id(dst)}", trusted=False)) return self.tag(dst, Provenance(root.root_id, root.trusted, root.edges + (edge,))) LEDGER = ProvenanceLedger() def load_tool_response(raw: bytes, tool_call_id: str) -> Any: """Parse an untrusted MCP tool response. The parsed structure is *not* trusted merely because json.loads produced it.""" parsed = json.loads(raw) return LEDGER.tag(parsed, Provenance(root_id=tool_call_id, trusted=False, edges=("json.loads",))) def re_serialize(obj: Any) -> str: """Any re-serialization must preserve provenance. If the source is unknown, the output is tainted by default.""" out = json.dumps(obj) return LEDGER.derive(obj, out, "json.dumps") def assemble_prompt(context: Any) -> str: """Prompt assembly is a re-serialization boundary and a common laundering site. The resulting string inherits the taint of its inputs.""" block = re_serialize(context) prompt = f"{block}" return LEDGER.derive(block, prompt, "fstring") def check_sink_capability(arg: Any, sink: str) -> None: """Capability check at a privileged sink. Inspects the derivation root, not the argument's runtime type.""" prov = LEDGER.resolve(arg) if prov is None: raise TaintLaunderingError( f"sink={sink!r}: argument has no resolvable provenance; refusing call" ) if not prov.trusted: raise TaintLaunderingError( f"sink={sink!r}: argument derives from untrusted root {prov.root_id!r} " f"via edges {prov.edges!r}; declassification required before use" ) def invoke_filesystem_write(prompt_text: str, path: str, body: str) -> None: # The prompt text is the attacker-influenced channel; the sink must reject # it if it carries taint from the tool response. check_sink_capability(prompt_text, sink="filesystem.write") check_sink_capability(path, sink="filesystem.write") check_sink_capability(body, sink="filesystem.write") # ... actual write would occur here ... def handle_tool_result(raw: bytes, tool_call_id: str) -> None: response = load_tool_response(raw, tool_call_id) context = response.get("context", {}) if isinstance(response, dict) else {} # The attacker-controlled field is re-serialized into a prompt block. prompt = assemble_prompt(context) # A downstream model now "decides" to write a file. The sink must refuse. invoke_filesystem_write(prompt_text=prompt, path="notes.txt", body="benign") ```