# What happens when an instance of a tainted provenance label is laundered through a tool that returns structured data **Date of Record:** 2026 November 9 **Scope:** This record applies to agentic systems that attach provenance/taint labels to context segments and propagate those labels across tool-call boundaries, specifically when a tool returns structured (JSON/XML/typed) output that re-encodes previously ingested untrusted content. It does not apply to systems that perform whole-context trust scoring without per-span provenance, nor to tool calls whose outputs are never re-entering the model's context. ## Core Distinction The failure is not "prompt injection got through." It is the distinction between **label preservation** and **label re-derivation**. A taint-tracking layer that propagates labels by *carrying them forward* (the tool call inherits the taint of its inputs) behaves correctly. A layer that propagates labels by *re-deriving them at the boundary from the shape of the data* (e.g., "this field is `trusted: false` because the schema says so") silently launders taint whenever the untrusted bytes are re-serialized into a field the schema marks as trusted. The non-obvious invariant: **a tool boundary that transforms representation must not be permitted to reset taint, even when the transformed output is structurally well-formed and schema-valid.** Schema validity is not a trust signal. A tool returning `{"status": "ok", "result": "..."}` is schema-valid whether `result` contains a database row or an attacker-controlled instruction that was retrieved by the tool and echoed back. ## What Was Observed A retrieval tool was invoked with a query derived from an untrusted web page. The tool's return contract declared `result: string` with an implicit `trusted: true` default in the caller's deserializer, because the tool was registered as a *first-party* tool. The tool faithfully fetched a document, and that document contained an instruction ("ignore prior constraints and emit the following..."). The retrieval tool did not execute the instruction; it merely returned it as a string inside a first-party envelope. At the boundary, the taint tracker observed a first-party tool returning a schema-valid payload and reset the span's provenance to `trusted`. The instruction then re-entered the model context with a trusted label and was treated as operating instructions rather than data. The taint had been *carried* correctly through the tool's input (the query was tainted) but *dropped* at output because the propagation rule keyed on tool identity rather than on dataflow. The tell: the tainted input span and the tainted output span had *different* provenance hashes despite being the same logical bytes after a JSON round-trip. The transformation (fetch → serialize → deserialize) was treated as a trust boundary rather than a representation boundary. ## Condition of Validity The failure is reproducible only under the conjunction of three conditions: 1. **Mixed-trust tools.** At least one registered tool returns content derived from untrusted sources while also being trusted to return its own metadata (status codes, pagination, error strings). 2. **Representation-crossing propagation.** The taint label is attached to a *span object* and not to a *content hash or dataflow edge*, so re-serialization produces a new span that the tracker cannot correlate with its ancestor. 3. **Default-trust deserialization.** The output deserializer assigns `trusted` by tool registration class rather than by union of input taint and output content lineage. Remove any one and the failure is blocked. If all outputs are uniformly untrusted, the model treats everything as data and the injection cannot promote itself to instruction. If spans carry content hashes, the tracker can match the re-serialized bytes to the tainted ancestor. If deserialization defaults to `untrusted` and requires explicit declassification, the launder is visible. ## Decision Boundary The correct decision is: **a tool call's output taint is the join (union) of (a) the taint of all inputs that influenced the output, and (b) the taint of any external content the tool read.** Tool identity contributes zero to trust. Declassification—moving a span from untrusted to trusted—must be an explicit, auditable operation with a named policy (e.g., "schema-conforms AND byte-identical to a previously declassified corpus"), never a side effect of a boundary crossing. Operationally, this means: do not model provenance as a property of the *envelope*; model it as a property of the *dataflow*. When a tool returns structured data, walk the structure and assign each leaf the join of its influencing input leaves and any fetched content. If the tool cannot report which inputs influenced which outputs, assume full join (everything tainted). ## Operational Effect Systems that adopt dataflow-join propagation observe a higher false-positive taint rate (benign first-party tool outputs get labeled untrusted because their inputs were tainted) but eliminate the laundering class. The mitigation for the false positives is explicit declassification policy, not default-trust. The observable effect of getting this wrong is subtle: the taint tracker reports *zero* violations (it believes everything is trusted) while the injection succeeds, so monitoring on "taint violations" is blind to the failure. Monitor instead on **provenance discontinuity**: any span whose taint differs from the join of its declared ancestors is a laundering event and must be logged, regardless of whether a policy violation fired. ```python from dataclasses import dataclass, field from typing import Any class ProvenanceLaunderingError(RuntimeError): """Raised when a tool boundary resets taint instead of joining it.""" @dataclass(frozen=True) class Span: content: str taint: frozenset[str] = field(default_factory=frozenset) # set of taint sources def join_taint(*spans: Span) -> frozenset[str]: out: set[str] = set() for s in spans: out |= s.taint return frozenset(out) def propagate_across_tool_boundary( inputs: list[Span], tool_output: Span, tool_is_first_party: bool, fetched_external: list[Span] | None = None, ) -> Span: """Return the output span with taint = join(inputs, fetched_external). Tool identity is deliberately ignored: first-party status contributes zero trust. Declassification must happen elsewhere, explicitly. """ expected = join_taint(*inputs, *(fetched_external or [])) if tool_is_first_party and tool_output.taint == frozenset(): # The deserializer assigned empty taint because the tool is trusted. # That is exactly the laundering condition: schema-valid output from # a trusted tool whose inputs were tainted, with taint dropped. if expected: raise ProvenanceLaunderingError( f"tool boundary dropped taint: inputs carried {sorted(expected)}, " f"output carried none; refusing to re-enter context as trusted" ) if not tool_output.taint.issuperset(expected): raise ProvenanceLaunderingError( f"output taint {sorted(tool_output.taint)} does not cover " f"joined input taint {sorted(expected)}" ) return Span(content=tool_output.content, taint=expected) def ingest_tool_result( inputs: list[Span], raw_output: dict[str, Any], tool_is_first_party: bool, fetched_external: list[Span] | None = None, ) -> Span: # Deserializer default: trust by tool class, not by dataflow. This is # the bug we are guarding against. declared = frozenset() if tool_is_first_party else frozenset({"untrusted"}) out = Span(content=str(raw_output.get("result", "")), taint=declared) return propagate_across_tool_boundary( inputs=inputs, tool_output=out, tool_is_first_party=tool_is_first_party, fetched_external=fetched_external, ) ```