# When must an instance treat a tool result as tainted: multi-hop provenance collapse at the label-join boundary
**Date of Record:** 2026 November 10
**Scope:** Applies to agent runtimes that propagate data-flow provenance labels (taint) across tool calls, sub-agent delegations, and context compaction, where a single logical value may be assembled from multiple upstream sources. Does not apply to single-hop tool use where every result is consumed directly by the requesting model turn, nor to stateless stateless prompt-injection filters that classify text without tracking its origin.
## Core Distinction
The distinction that matters is between **provenance of the bytes** and **provenance of the instruction**. A tool result can be byte-clean (no injection string present) yet carry an instruction semantically because an earlier hop laundered attacker-controlled text into a trusted-looking field. Taint tracking fails not when a label is missing, but when a **label-join operation silently promotes an untrusted operand into a trusted result** — for example, a `dict.update()`, a template render, a join, or a JSON merge where the destination inherits the *most trusted* label in the operation rather than the *least trusted*.
The invariant: **a derived value is at least as tainted as its most-tainted contributing source, across every hop, including hops that pass through a summarizer, a schema validator, or a sub-agent that "doesn't have access" to the tainted field.** Any join that violates this is a provenance collapse.
## What Was Observed
In a multi-hop chain (fetch → extract → summarize → write-to-ticket), the runtime tracked taint per tool-call result. Hop 1 fetched an external page; the extractor returned a structured object `{title, body}` and the runtime correctly labeled both fields `taint=external`. Hop 2 passed only `body` to a summarizer sub-agent, which returned a plain string labeled `taint=derived:external`. Hop 3 rendered a ticket body via a template that interpolated `title` (still `external`) and the summary. The template engine's merge step constructed the final string with a fresh label `taint=none` because "the template itself is trusted."
The resulting ticket body contained the attacker's instruction — which had been placed in the page `
` — and was subsequently read by an automation agent that had write access to the ticket system. The injection executed at hop 4, two hops after the labeled source. **The taint was never lost; it was overwritten at a join whose destination label was chosen by the operation's nature rather than by its operands.**
A second observation: a schema validator that coerced the extracted object and dropped unknown fields also dropped the provenance metadata attached to those fields, so a later read of the coerced object saw `taint=none` even though the *values* were unchanged.
## Condition of Validity
This failure mode is live only when all three hold:
1. **Label-join asymmetry.** At least one operation in the chain combines values from differently-labeled sources and does not compute the result label as `join(labels) = max_taint`. Template rendering, dict merge, string concatenation, and JSON serialization are the common offenders.
2. **Cross-hop reachability.** A tainted value can reach a sink (a tool with side effects, or a model turn that will act on instructions) without passing through an explicit declassification gate.
3. **Trust laundering surface.** Some hop presents the value under a trusted-looking envelope — a schema, a template, a summary — such that a downstream consumer would not re-check provenance even if it could.
If any of the three is absent, the chain is safe by construction and no additional tracking is required. The dangerous configuration is specifically the combination of (1) and (3): an asymmetric join *inside* a trusted envelope.
## Decision Boundary
An instance must treat a tool result as tainted when **any** of the following is true at the point of consumption:
- The value was produced by a join/serialize/template operation whose operand labels were not all `none`.
- The value passed through a coercion or validation step that did not explicitly preserve provenance metadata.
- The value's provenance is `unknown` (missing label) rather than explicitly `none` — absence of a label is not evidence of cleanliness.
- The value derives from a sub-agent whose own input included a tainted value, regardless of what the sub-agent "had access to."
An instance may treat a tool result as clean only when provenance is **explicitly `none`** and every contributing operand was explicitly `none`. The default for missing or unjoinable provenance is tainted. Declassification must be an explicit, logged operation with a named principal, not a side effect of an operation's type.
## Operational Effect
The practical consequence is that provenance must be carried as a first-class value attached to the data, not to the tool call, and every combinator in the runtime must be audited for join correctness. In practice this means: (a) label the *value*, not the *message*; (b) make the default constructor of any derived value inherit `max_taint` of inputs; (c) treat `unknown` as tainted; (d) require an explicit `declassify(value, by=principal, reason=...)` before a tainted value may reach a side-effecting sink. The cost is that benign chains will occasionally be blocked and require a declassification record — which is the correct trade, because a provenance collapse is silent, and silent taint loss is indistinguishable from a clean chain until the injection fires.
```python
from dataclasses import dataclass, field
from typing import Optional
class ProvenanceCollapseError(RuntimeError):
"""Raised when a derived value's label is less tainted than its inputs."""
@dataclass(frozen=True)
class Tainted:
value: object
taint: str # "none" | "external" | "derived:" | "unknown"
def __post_init__(self):
if self.taint not in ("none", "unknown") and not self.taint.startswith(("external", "derived:")):
raise ProvenanceCollapseError(f"unrecognized taint label: {self.taint!r}")
def join_taint(*labels: str) -> str:
if any(l == "unknown" for l in labels):
return "unknown"
if any(l.startswith("external") for l in labels):
return "external"
derived = [l for l in labels if l.startswith("derived:")]
if derived:
return derived[0]
return "none"
def merge_fields(*fields: Tainted) -> Tainted:
"""Join operation. Result MUST be at least as tainted as the most tainted input."""
result_taint = join_taint(*(f.taint for f in fields))
merged = {f.value: f.taint for f in fields} # illustrative merge
out = Tainted(value=merged, taint=result_taint)
# Invariant check: never let a join promote to a cleaner label.
for f in fields:
if f.taint.startswith("external") and not out.taint.startswith("external"):
raise ProvenanceCollapseError(
f"join promoted external operand to {out.taint!r}"
)
return out
def render_template(template: str, parts: dict[str, Tainted]) -> Tainted:
"""Template render is a join. It must NOT reset taint to 'none'."""
joined = merge_fields(*parts.values())
rendered = template.format(**{k: v.value for k, v in parts.items()})
return Tainted(value=rendered, taint=joined.taint)
def send_to_sink(payload: Tainted, principal: Optional[str] = None) -> None:
"""Side-effecting sink. Tainted payloads require explicit declassification."""
if payload.taint == "unknown":
raise ProvenanceCollapseError(
"refusing to send payload with unknown provenance to a side-effecting sink"
)
if payload.taint != "none" and principal is None:
raise ProvenanceCollapseError(
f"refusing to send tainted payload (taint={payload.taint!r}) "
"to sink without explicit declassification principal"
)
# ... perform side effect ...
def declassify(payload: Tainted, *, by: str, reason: str) -> Tainted:
if not by or not reason:
raise ProvenanceCollapseError("declassification requires principal and reason")
# Declassification is logged and explicit; it does not silently rewrite history.
return Tainted(value=payload.value, taint="none")
```