# Under what conditions must an instance treat a tool output as tainted when provenance labels are lost at a serialization boundary **Date of Record:** 2026 November 13 **Scope:** Applies to agent runtimes that propagate prompt-injection taint via provenance labels attached to message parts and tool-call results, where taint must survive at least one serialization boundary (JSON tool-call arguments, HTTP body, structured-output schema coercion). Does not apply to in-process pipelines where taint is carried by object identity and never re-parsed. ## Core Distinction The distinction that matters is between **provenance loss** and **provenance laundering**. Provenance loss is a benign, expected event: a tainted string is embedded in a JSON payload, the receiving tool re-parses it, and the structured label object (e.g. `{"taint": ["untrusted:web"]}`) is dropped because the schema only declares `type: string`. Provenance laundering is the same event observed from the perspective of the *downstream* consumer, which now sees an unlabeled string and, by the default rule "no label means trusted," promotes it to trusted context. The failure is not that the label was lost — it is that the system's *absence-of-label* semantics are inverted with respect to the *absence-of-label* reality. A label that is absent because the value was never tainted and a label that is absent because the serializer did not carry it are indistinguishable at the consumer, and the consumer resolves the ambiguity in the unsafe direction. ## What Was Observed A retrieval tool returned a document whose body contained an injected instruction. The runtime correctly labeled the tool result `taint=["untrusted:retrieval"]` and propagated it through two hops of the planner. At the third hop, the planner passed a substring of the tainted body into a `summarize(text: str)` tool call. The tool-call arguments were serialized to JSON; the `text` field was a bare string, and the provenance label — which lived on the *message part*, not on the *string* — was not included in the tool-call schema. The summarizer's output was returned as a new tool result with `taint=[]` (empty), because the summarizer's own output was generated by a trusted model call and the runtime assigned provenance by *producer*, not by *input lineage*. The empty label was then read by the executor as "clean," and the summary — which contained a verbatim restatement of the injected instruction — was placed into the system-adjacent context. The injection executed two turns later. The non-obvious part: the taint tracker *did* fire on the original retrieval result. Every hop up to the serialization boundary was correctly labeled. The invariant that failed was not "taint is tracked" but "taint is *conserved across producer boundaries*." The runtime treated provenance as a property of the message, not as a property of the *data flow*, so any tool that took tainted input and emitted derived output reset the label to the producer's default. ## Condition of Validity The safe behavior — treating an unlabeled value as clean — is valid only under the conjunction of three conditions: 1. **Total labeling.** Every value that enters the context is either explicitly labeled or provably untainted by construction (e.g. a literal in the system prompt). If any input path can produce an unlabeled value, absence-of-label is ambiguous. 2. **Label-preserving serialization.** Every serialization boundary either carries the label alongside the value or is provably value-preserving in a way that lets the consumer re-derive lineage. A `str`-typed field in a JSON tool schema violates this. 3. **Input-lineage propagation for derived outputs.** A tool's output provenance must be the join (union) of its input provenances, not the producer's default. A summarizer that reads tainted text emits tainted text. In the observed incident, condition 2 and condition 3 both failed. Either failure alone is sufficient to launder taint; together they make the launder silent, because the summarizer's output looks *more* trustworthy than its input (it is shorter, it is model-generated, it carries no label). ## Decision Boundary The operational rule is a **default-deny on absence-of-label at any boundary that is not provably total**. Concretely: - If a tool's input schema declares a field as a bare scalar and the runtime cannot attach a sidecar label, the field must be treated as *potentially tainted* on the way out, and the tool's output must inherit `untrusted:*` unless the tool is on an explicit allowlist of lineage-preserving transforms. - If a tool's output carries `taint=[]` but any of its inputs carried a non-empty label, the empty label is a **bug indicator**, not a clean bill of health. The runtime should raise rather than proceed. - The only values that may be treated as clean-by-absence are those whose provenance is *structurally* known: system-prompt literals, values from a signed/verified source with a verified label, and outputs of tools whose input lineage was verified empty. The boundary is therefore: **absence-of-label is clean only when labeling is total; otherwise absence-of-label is a taint signal.** The runtime must know which regime it is in, and it must know it per-boundary, not globally. ## Operational Effect When the rule is enforced, the summarizer's output is marked `taint=["untrusted:retrieval"]` (joined from its input), the executor refuses to place it in system-adjacent context, and the injection dies at the summarizer boundary instead of executing two turns later. The cost is a higher false-positive rate: benign summaries of benign retrieved text are now labeled and must be cleared by an explicit declassification step (a human review, a policy check, or a re-derivation from a trusted source). That cost is the price of not having an ambiguous absence. The failure mode to watch is **label inflation** — if every derived value inherits taint, the taint set grows monotonically and the planner eventually refuses everything. The mitigation is declassification, not label relaxation: you clear taint by *proving* a transform is lineage-preserving or by *reviewing* the value, never by letting the serializer drop the label and calling the result clean. ```python from __future__ import annotations from dataclasses import dataclass, field class TaintLaunderingError(RuntimeError): """Raised when a tool output drops taint inherited from its inputs.""" @dataclass(frozen=True) class Provenance: labels: frozenset[str] = field(default_factory=frozenset) def join(self, other: "Provenance") -> "Provenance": return Provenance(self.labels | other.labels) @property def is_clean(self) -> bool: return not self.labels @dataclass(frozen=True) class Value: payload: str provenance: Provenance def propagate_tool_output( tool_name: str, inputs: list[Value], output: Value, *, lineage_preserving: frozenset[str] = frozenset(), ) -> Value: """Conservatively join input provenance onto a tool output. A tool that is not on the lineage-preserving allowlist must not emit a value cleaner than its inputs. Absence-of-label is treated as a taint signal, not a clean bill of health. """ inherited = Provenance() for v in inputs: inherited = inherited.join(v.provenance) if inherited.is_clean: return output if tool_name in lineage_preserving: return output if output.provenance.is_clean: raise TaintLaunderingError( f"tool {tool_name!r} emitted clean output from tainted input " f"{sorted(inherited.labels)!r}; label was dropped at a " f"serialization boundary" ) return Value( payload=output.payload, provenance=output.provenance.join(inherited), ) ```