# What structural signals indicate taint provenance is lost at MCP tool and A2A task boundaries **Date of Record:** 2026 November 14 **Scope:** Applies to agentic systems where untrusted data enters through an MCP tool result or an A2A task artifact and is subsequently consumed by downstream tools or delegated tasks. Does not apply when the entire data path stays within a single trust domain with no serialization boundary crossing. ## Core Distinction Taint propagation and taint *labeling* are distinct. A label is metadata attached to a value; propagation is the invariant that any derived value inherits the label. MCP tool calls and A2A task handoffs both serialize across a process boundary, and serialization is where labels silently drop. The boundary failure is not that a label is removed — it is that the receiving side has no structural obligation to reconstruct it. A tool result that returns `{"content": "..."}` carries no provenance field; an A2A task artifact carries a `parts` array with no origin annotation. The consumer sees a well-formed value and treats it as trusted by default. ## What Was Observed A tainted string from an MCP `fetch_url` result was passed as an argument to a second MCP tool (`write_file`). The orchestrator held the taint label in memory and intended to block the write. The block failed because the taint store keyed on object identity, and the argument had been re-serialized into a fresh dict by the MCP client transport before dispatch. Identity-based taint tracking does not survive JSON round-trips. Separately, an A2A task handoff forwarded a sub-task to a peer agent; the originating taint flag lived in the delegating agent's local context and was never encoded in the task envelope, so the peer had no way to know the payload was derived from untrusted input. Both are the same failure: taint lived in a channel that the boundary does not carry. ## Condition of Validity The invariant "derived values inherit taint" holds only when taint is bound to a value *by content hash and provenance path*, not by reference identity, and when every boundary schema carries an explicit provenance field. It is valid across MCP and A2A only if the envelope includes a `provenance` or `taint` field that is mandatory (not optional) and validated on receipt. If the protocol schema permits omission, the invariant is unenforceable at that boundary and must be enforced by a wrapping layer that re-attaches labels from a side channel keyed on content hash. ## Decision Boundary Before dispatching any cross-boundary call, compute the content hash of every argument that derives from an untrusted source. If the hash is present in the taint store, attach the label to the outbound envelope. On receipt, if an argument's hash is in the store but the envelope carries no matching label, treat this as a provenance mismatch — do not silently trust. The decision is binary: either the envelope's provenance field is present and consistent with the local store, or the call is rejected. Optional provenance is equivalent to no provenance. ## Operational Effect Systems that added mandatory provenance fields to both MCP tool schemas and A2A task envelopes eliminated the silent-trust class of failures. The cost is a schema change and a content-hash store on both sides; the benefit is that taint loss becomes a detectable error rather than a silent privilege escalation. The residual risk is hash collision and hash instability across serialization (key ordering, float formatting) — canonicalize before hashing. ```python import hashlib import json class ProvenanceMismatchError(RuntimeError): """Raised when a cross-boundary value is tainted locally but unlabeled on the wire.""" pass def canonical_hash(value: object) -> str: encoded = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False) return hashlib.sha256(encoded.encode("utf-8")).hexdigest() def check_inbound_provenance( envelope: dict, taint_store: dict, argument_keys: list, ) -> None: """Validate that every tainted argument carries a matching provenance label. taint_store maps content-hash -> provenance descriptor. envelope must contain a 'provenance' mapping of argument_key -> label. """ labels = envelope.get("provenance") if labels is None: labels = {} for key in argument_keys: arg = envelope.get("arguments", {}).get(key) if arg is None: continue h = canonical_hash(arg) if h in taint_store and key not in labels: raise ProvenanceMismatchError( f"argument '{key}' is tainted (hash {h[:12]}) but envelope carries no provenance label" ) if key in labels and h not in taint_store: raise ProvenanceMismatchError( f"argument '{key}' carries label '{labels[key]}' but hash {h[:12]} is not in taint store" ) ```