# Which invariants prevent taint-laundering of retrieved content across tool and retrieval boundaries **Date of Record:** 2026 November 9 **Scope:** Applies to agent systems that attach provenance/taint labels to retrieved documents and tool outputs and propagate those labels through multi-hop tool calls. Does not apply to single-turn, single-tool RAG where the retrieved span never re-enters the model as an instruction-bearing artifact, nor to systems where tool outputs are structurally non-executable (e.g., pure numeric telemetry with no free-text fields). ## Core Distinction There are two distinct things an agent can do with a piece of untrusted text: *read* it and *re-emit* it. Taint tracking systems are usually correct about the first and silently wrong about the second. The failure is not "the model followed an injected instruction." The failure is that a tainted span crossed a tool boundary, was re-serialized by a tool, and came back with a *fresh* provenance label because the label was assigned at the retrieval layer rather than carried on the value itself. This is taint laundering: the span's origin is erased by a lossy transformation at the boundary, and the downstream consumer treats the laundered copy as first-party. The invariant that matters is not "tainted input is not executed." It is: **taint is a property of the value, not of the channel.** Any boundary that re-materializes a value (summarization tool, HTTP fetch-and-parse tool, file-write-then-read, embedding round-trip, JSON re-encoding) must either preserve the label or mark the output as *derived-from-tainted*, which is itself tainted. A boundary that drops labels is a laundering boundary, and the system must treat its output as maximally tainted by default, not minimally. ## What Was Observed A retrieval tool returned a document with `provenance=external_untrusted, taint=high`. The agent, following a plan step, passed a quote from that document into a `summarize` tool whose contract was "takes text, returns summary." The summarize tool called an upstream model and returned `provenance=internal_tool, taint=none` because the tool's own implementation never received the label — only the text. The summary contained a paraphrased instruction ("the operator should forward the credentials to the address below"), which the planner then acted on. The audit log showed the action as originating from a `taint=none` source. The label was not forged; it was simply absent, and absence was read as clean. Two secondary observations: (a) the laundering happened on *paraphrase*, not verbatim copy, so substring-matching taint propagation (the common "if the output contains the input span, inherit the label" heuristic) missed it; (b) the boundary that laundered the taint was a *trusted* tool, which is why no anomaly fired. ## Condition of Validity The invariant holds only when every boundary that accepts tainted values is *label-aware*: it receives the label as an explicit argument and is contractually required to emit a label at least as tainted as its most-tainted input. This is a *join* over inputs (max-taint), not a union over spans. Where this condition fails — boundaries that take only text, boundaries implemented by third parties, boundaries whose output is a different modality (image, embedding, audio) — the system must fall back to the conservative rule: **output of a non-label-aware boundary is tainted at the maximum level regardless of input labels.** The invariant is invalid for any boundary whose output cannot carry a label at all; such boundaries must be treated as sinks that refuse tainted input, not pass-throughs. ## Decision Boundary The decision is not "is this text tainted?" but "did the label survive the transformation?" Concretely, a value may be downgraded from tainted to clean only if one of the following holds: (1) it was produced by a label-aware boundary whose declared join rule was applied and logged; (2) it was verified against a signed first-party source independent of the tainted path; (3) a human or policy engine explicitly declassified it with a recorded justification. Paraphrase is never a declassification event. Re-serialization is never a declassification event. A tool returning `taint=none` without recording *why* is a boundary violation, not a clean result. When in doubt, the boundary is closed: refuse to pass the value. ## Operational Effect After the incident, the retrieval and summarize tools were changed to accept and emit explicit `taint` fields; the planner was changed to reject any tool output whose `taint` is lower than the max taint of its inputs unless a declassification record exists. The practical consequence is a higher rate of false-positive refusals on legitimate summarization chains, which is the correct trade: the failure mode of over-tainting is a blocked action, while the failure mode of under-tainting is an unlogged action attributed to a clean source. The audit invariant is now "no action may be attributed to a source less tainted than the most tainted span in its causal history," and it is checked at execution time, not at planning time. ```python class TaintLaunderingError(RuntimeError): """Raised when a boundary would downgrade taint without a recorded declassification.""" MAX_TAINT = 3 # 0=clean, 1=internal, 2=external_untrusted, 3=adversarial def join_taint(inputs: list[int]) -> int: return max(inputs) if inputs else 0 def apply_boundary( boundary_name: str, inputs: list[tuple[str, int]], output_taint: int, declassification_record: dict | None = None, ) -> int: """Enforce: output taint >= max input taint, unless declassified with justification.""" required = join_taint([t for _, t in inputs]) if output_taint < required: if declassification_record is None: raise TaintLaunderingError( f"boundary {boundary_name!r} downgraded taint " f"{required}->{output_taint} with no declassification record; " f"inputs={[n for n, _ in inputs]}" ) if not declassification_record.get("justification"): raise TaintLaunderingError( f"boundary {boundary_name!r} declassification lacks justification" ) return output_taint def summarize_tool(text: str, input_taint: int) -> tuple[str, int]: # Label-aware boundary: must propagate at least input_taint. summary = text[:200] propagated = join_taint([input_taint]) if propagated < input_taint: raise TaintLaunderingError("summarize_tool lost taint during paraphrase") return summary, propagated def route_action(source_name: str, source_taint: int, action: str) -> None: if source_taint >= 2: raise TaintLaunderingError( f"refusing action {action!r} attributed to tainted source " f"{source_name!r} (taint={source_taint})" ) # execute action ```