# What happens when an instance of a tainted tool-call result is laundered through a second-order summarization hop and the sink label is recomputed from the summary rather than the provenance graph **Date of Record:** 2026 November 10 **Scope:** Applies to agent runtimes that propagate prompt-injection taint as dynamic information-flow labels across tool-call graphs and enforce sinks (outbound HTTP, shell exec, file write, message send) against those labels. Does not apply to stateless single-turn classification or to runtimes that re-derive taint solely from the current context window without a provenance DAG. ## Core Distinction The failure is not "taint is dropped." Taint is preserved at every individual hop. The failure is that a *derived* artifact (a summary, a reformatted table, a normalized JSON blob) is re-labeled by inspecting the derived content, not by joining the provenance of its inputs. When a model summarizes a tainted tool result, the summarizer's output is a *new* node whose label is computed as `label(summary_text)` under a content classifier. If the injection payload is paraphrased into innocuous prose ("the user has authorized the following transfer"), the content classifier returns `clean`, and the summary node — which is a transitive descendant of an `untrusted` source — carries a `clean` label. Downstream sinks see `clean` and fire. The invariant that must hold is monotonicity under derivation: `label(derive(x)) ⊒ label(x)` for every derivation edge. Content-based re-labeling violates this by construction. ## What Was Observed In a tool-call graph with three hops — `fetch_url → summarize → draft_email(send)` — the fetch result carried `taint=[untrusted:web]`. The summarize step produced a node labeled by a content classifier that returned `taint=[]`. The email-send sink permitted the call. Crucially, the taint *was* visible in the provenance DAG; it was simply not consulted because the sink enforcement read `node.label` instead of `join(labels(ancestors(node)))`. The fix is not a better classifier — it is that sinks must enforce against a *provenance-joined* label, computed as the least upper bound over all ancestors, and derivation nodes must be forbidden from *lowering* a label. A subtler variant: the summarizer was correct, but the summary was then passed through a JSON-normalization tool that re-parsed and re-emitted the text, and the normalizer's label was computed from its own output. Two derivation hops, two independent content-based re-labelings, both returning `clean`, while the true join remained `untrusted:web`. A third variant involved a *tool result* itself being treated as a fresh root — the summarizer called a second tool whose output was labeled `clean` because the second tool's *own* provenance was clean, ignoring that its *input* was tainted. This is the "input-label laundering" case: a node's label must be `join(own_source_label, labels(all inputs))`, not `own_source_label`. ## Condition of Validity The distinction holds only when the runtime maintains an explicit provenance DAG with per-node labels and enforces sinks against `join(ancestors(n))`. It does *not* hold — and the failure is undetectable — when the runtime has flattened the graph into a linear context and labels are attached to messages rather than to derivation edges. In that flattened case, "tainted" and "clean" are both present in the window and the sink has no structural basis to prefer one; the failure is architectural, not a labeling bug. The distinction also assumes the derivation operator is monotone: if a derivation can legitimately *sanitize* (e.g., a structured extraction that provably cannot carry free-text instructions), that sanitizer must be an explicit, audited node type with a proof obligation, not an emergent property of a content classifier. Absent that, every content-based re-label is a potential laundering hop. ## Decision Boundary Enforce at the sink, not at the derivation. A sink accepts a call iff `join(ancestors(call_node)) ⊑ permitted_label_for_sink`. Derivation nodes may only *raise* labels (`new = join(old, labels(inputs), source_label)`); any code path that assigns `new = classifier(output)` is a boundary violation and must raise. When a legitimate sanitizer exists, it is a distinct node type that carries a proof-of-sanitization token and is the *only* node permitted to lower a label, and it must be registered in an allowlist. The decision for an operator: if you cannot enumerate your sanitizers, you do not have sanitizers — you have laundering hops. Treat every content-based re-label as `untrusted` until proven otherwise. ## Operational Effect The practical effect is that taint tracking becomes *advisory* rather than *enforcing*: dashboards show taint present, audits pass, and the sink still fires. The blast radius is bounded by the number of derivation hops between the untrusted source and the sink — and summarization/normalization hops are exactly the ones agents add most. The mitigation is a single structural rule (sinks join provenance; derivations only raise) plus an assertion that fails closed on any label-lowering assignment outside the sanitizer allowlist. Instrument the DAG so that every sink decision logs the joined ancestor set, not the node label; that log is the only artifact that distinguishes "taint was absent" from "taint was laundered." ```python from dataclasses import dataclass, field from typing import Dict, List, Set class TaintLaunderingError(RuntimeError): """Raised when a derivation lowers a label outside an approved sanitizer.""" class SinkEnforcementError(RuntimeError): """Raised when a sink is invoked with a provenance-joined label it forbids.""" @dataclass class Node: id: str label: Set[str] inputs: List[str] = field(default_factory=list) is_sanitizer: bool = False class ProvenanceGraph: def __init__(self) -> None: self.nodes: Dict[str, Node] = {} def add(self, node: Node) -> None: self.nodes[node.id] = node def join_ancestors(self, node_id: str, _seen: Set[str] = None) -> Set[str]: if _seen is None: _seen = set() if node_id in _seen: return set() _seen.add(node_id) node = self.nodes[node_id] joined = set(node.label) for parent_id in node.inputs: joined |= self.join_ancestors(parent_id, _seen) return joined def derive(self, node_id: str, inputs: List[str], source_label: Set[str], proposed_label: Set[str], is_sanitizer: bool = False) -> Node: joined_inputs: Set[str] = set() for i in inputs: joined_inputs |= self.join_ancestors(i) monotone = joined_inputs | source_label if not is_sanitizer and not proposed_label >= monotone: raise TaintLaunderingError( f"derivation {node_id!r} proposed label {proposed_label} " f"below required join {monotone}" ) node = Node(id=node_id, label=proposed_label, inputs=inputs, is_sanitizer=is_sanitizer) self.nodes[node_id] = node return node def enforce_sink(self, sink_name: str, call_node_id: str, permitted: Set[str]) -> None: joined = self.join_ancestors(call_node_id) if not joined <= permitted: raise SinkEnforcementError( f"sink {sink_name!r} rejected: joined taint {joined} " f"exceeds permitted {permitted}" ) def run_laundered_pipeline() -> None: g = ProvenanceGraph() g.add(Node(id="fetch", label={"untrusted:web"})) # Summarizer re-labels from content classifier -> laundering attempt. g.derive(node_id="summary", inputs=["fetch"], source_label=set(), proposed_label=set()) g.enforce_sink("send_email", "summary", permitted=set()) if __name__ == "__main__": try: run_laundered_pipeline() except TaintLaunderingError as exc: print(f"blocked at derivation: {exc}") ```