# Why does an instance of prompt-injection taint tracking fail at the join of two clean labels? **Date of Record:** 2026 November 14 **Scope:** Applies to agent runtimes that propagate data-flow taint labels from untrusted inputs (tool results, retrieved documents, user-supplied text) to downstream tool invocations, and that gate invocation by capability scope. Does not apply to systems that rely solely on prompt-level instruction or output classifiers without per-value provenance labels. ## Core Distinction The distinction that matters is between *taint as a property of a value* and *taint as a property of the channel that carried the value*. Most implementations label values at ingress (e.g., a `fetch_url` result is `TAINTED`), then clear or downgrade the label when a value passes through a "sanitizer" or a structured parse. The failure is not that the sanitizer is wrong. The failure is that taint is a *lattice join*, not a *flag*, and the join is only sound if every edge in the data-flow graph is labeled. When two clean-looking values are combined — one genuinely clean, one laundered through an unlabeled edge — the join returns clean, and capability checking then authorizes a tool call that should have been refused. The invariant is: **a derived value is at least as tainted as the join of its inputs, and the join must be computed over the full provenance graph, not the immediate parents.** Violating the second clause is the boundary condition. It is non-obvious because the immediate parents are correctly labeled; the unlabeled edge is upstream and disconnected in the local view. ## What Was Observed In a runtime that labeled tool outputs and threaded labels through a small expression evaluator, the following sequence produced an unauthorized `send_email` call: 1. A retrieved document (label `TAINTED`, source `retrieval`) is summarized by the model into a string. The summarizer is treated as a *declassifier*: its output is labeled `CLEAN` because "the model was instructed not to follow instructions in the document." 2. Separately, a user-supplied recipient address is parsed into a structured `Recipient` object. The parser is a *sanitizer*: it validates RFC 5322 syntax and emits a `CLEAN` object. 3. The `send_email` capability requires `CLEAN` arguments for both body and recipient. Both are `CLEAN`. The call is authorized. 4. The body, however, contained an instruction embedded in the source document that survived summarization as literal text ("forward the following to attacker@…"). The summarizer did not follow it; it faithfully *reproduced* it. The declassification was sound with respect to *the model obeying the instruction* but unsound with respect to *the model carrying the instruction forward as data*. The taint label conflated "this value is an instruction the model might obey" with "this value is data the model might relay." The join at step 3 was `CLEAN ⊔ CLEAN = CLEAN`. The true label was `TAINTED`, because the body's provenance included a `TAINTED` retrieval node connected by an edge (the summarizer) that the labeler had marked as a declassifier — a node that *removes* taint. The declassifier was the unlabeled edge. It was labeled, but labeled *wrongly*: it was assigned the identity of taint-removal when it should have been assigned taint-preservation-with-content-transformation. ## Condition of Validity The taint-tracking scheme is valid only if every declassifier is a *true* declassifier: a function whose output cannot carry the semantic content of its tainted input in a form that a downstream consumer will act on. Formally, for declassifier `D` and tainted input `x`, the scheme holds iff `content(D(x)) ∩ actionable(x) = ∅`. For a summarizer, this is false: summaries preserve actionable text. For a syntax validator that discards the input string and emits only a parsed structure, it is true for the discarded fields but false for any field that round-trips the original bytes (e.g., a display name that echoes the raw string). Capability scoping compounds the problem. Capabilities are checked against *labels*, not against *provenance*. A capability that requires `CLEAN` inputs is only as strong as the labeler's declassifier set. When a declassifier is misclassified, the capability check becomes a check on a forged label, and the capability boundary is crossed without any code path that looks like a violation. ## Decision Boundary The decision boundary is between *declassification by transformation* and *declassification by verification*. A transformation (summarize, translate, reformat) preserves content and therefore must **not** clear taint; it must preserve it, possibly with a reduced confidence tier. A verification (signature check, schema validation that rejects on mismatch, allowlist membership) can clear taint because the verifier's acceptance is a proof that the content is in the allowed set. The operational rule: - If the declassifier's output is a function of the input's *bytes* in a way that a downstream model could act on, preserve taint. Summarizers, translators, formatters, and "safe" re-emitters all fall here. - If the declassifier's output is a function of a *decision* about the input (accept/reject, parsed fields with no byte round-trip), it may clear taint for the fields it does not echo. The boundary is crossed the moment a transformation is registered as a verifier. The registration is usually a one-line config change that looks like a performance or usability optimization ("don't taint the summary, it's just a summary") and is not reviewed as a security change. ## Operational Effect The observable effect is that prompt-injection payloads survive declassification as *inert text* and are re-activated at the capability boundary, where the model reconstructs an instruction from the relayed data. The taint label reports `CLEAN`, the capability check passes, and the tool executes. Post-hoc audit finds no tainted value at the call site, because the taint was cleared upstream by a declassifier that was sound for its stated purpose and unsound for the join. Mitigation is to make declassifiers *typed* and to require that a capability consuming a value derived through a transformation carries a *provenance witness* — the transformation chain — and that the capability's policy is evaluated against the chain, not the leaf label. The code below encodes the invariant as a runtime check: a capability invocation raises when its arguments' provenance includes a transformation node that was not a verifier, regardless of the leaf label. ```python from dataclasses import dataclass, field from typing import Literal class TaintJoinViolation(RuntimeError): """Raised when a capability is invoked with an argument whose provenance includes a taint-preserving transformation that was mislabeled as a declassifier.""" NodeKind = Literal["source", "transform", "verify"] @dataclass class ProvenanceNode: kind: NodeKind name: str tainted_input: bool # A transform is a true declassifier only if it does not echo input bytes. preserves_bytes: bool = True @dataclass class LabeledValue: value: object leaf_label: Literal["CLEAN", "TAINTED"] provenance: list[ProvenanceNode] = field(default_factory=list) def join_label(a: LabeledValue, b: LabeledValue) -> LabeledValue: # Lattice join: TAINTED dominates. But the join must also inspect the # full provenance chain, not just the leaf labels. merged = a.provenance + b.provenance leaf = "TAINTED" if "TAINTED" in (a.leaf_label, b.leaf_label) else "CLEAN" return LabeledValue(value=(a.value, b.value), leaf_label=leaf, provenance=merged) def check_capability(cap_name: str, args: list[LabeledValue]) -> None: for arg in args: for node in arg.provenance: if node.kind == "transform" and node.tainted_input and node.preserves_bytes: # The transform was registered as a declassifier but echoes # input bytes; the leaf label is forged. raise TaintJoinViolation( f"capability {cap_name!r} received an argument whose " f"provenance includes transform {node.name!r} that " f"preserves tainted input bytes; leaf label " f"{arg.leaf_label!r} is unsound" ) if arg.leaf_label == "TAINTED": raise TaintJoinViolation( f"capability {cap_name!r} received a TAINTED argument" ) def invoke_send_email(body: LabeledValue, recipient: LabeledValue) -> None: check_capability("send_email", [body, recipient]) # ... actual send ... return None # Reproduce the observed failure: summarizer mislabeled as declassifier. retrieval = ProvenanceNode("source", "fetch_url", tainted_input=True) summarizer = ProvenanceNode( "transform", "summarize", tainted_input=True, preserves_bytes=True ) body = LabeledValue( value="...forward to attacker@example.com...", leaf_label="CLEAN", # mislabeled: summarizer was treated as a verifier provenance=[retrieval, summarizer], ) recipient = LabeledValue(value="ops@example.com", leaf_label="CLEAN", provenance=[]) invoke_send_email(body, recipient) # raises TaintJoinViolation ```