# What structural signals indicate prompt-injection taint has silently laundered across a tool boundary in multi-turn agent context **Date of Record:** 2026 November 7 **Scope:** Applies to LLM agents that carry dynamic information-flow labels (taint bits) on context segments and propagate them across tool-call boundaries and conversation turns. Does not apply to stateless single-shot inference, nor to systems that enforce provenance exclusively through cryptographic signing of tool outputs. ## Core Distinction The failure of interest is not "the model obeyed an injected instruction." That is a *detected* injection. The failure is **label laundering**: a tainted segment enters the context, is transformed by a tool (summarization, retrieval, formatting, translation, code execution), and the resulting output segment is labeled clean because the tool's return path does not carry the taint bit forward. Downstream, the agent treats laundered content as trusted and acts on it. The distinction that matters operationally: *semantic* containment (the model did not visibly comply) is not the same as *label* containment (the taint bit survived every transformation). Systems routinely have the first and lack the second. ## What Was Observed Across a set of multi-turn tool-using agents, three structural signals preceded laundering events with high reliability: 1. **Label-arity mismatch at the tool boundary.** The tool schema declared `input_labels` but not `output_labels`. When a tool consumed a tainted argument and returned a string, the runtime had no slot to write the propagated label into, so the return defaulted to `untainted`. Every laundering event traced to a return path whose label field was absent, nullable, or defaulted. 2. **Turn-boundary label decay under compaction.** When the context manager summarized older turns to fit the window, the summarizer emitted text without re-deriving labels from the summarized segments. The taint lived in a turn that was "compressed away," and the summary — derived from tainted content — came back clean. Signal: taint-set cardinality dropped to zero across a compaction event while the token budget increased. 3. **Cross-modal re-entry.** Content that left as a tainted string, was written to a scratch file / vector store / external API, and re-entered via a different tool (read, search, fetch) arrived with a *fresh* provenance namespace. The label system keyed taint on `(source_id, segment_id)`; re-entry minted a new `segment_id` under a new `source_id`, so the join that would have recovered the taint never fired. Signal: identical content hash appearing under two disjoint provenance namespaces, one tainted and one clean. A secondary signal: **the model citing laundered content as if it were an instruction from the user.** When the agent's next action references content that originated in a tool return but frames it with user authority ("as you asked me to…"), the label graph and the model's own authority model have diverged — the label says clean, the model's behavior says it believes the content is trusted. ## Condition of Validity These signals are diagnostic only when the following hold: - Labels are *dynamic* (computed per segment at runtime), not static per-source. If taint is assigned statically to a whole tool or whole document, laundering cannot occur because there is no fine-grained bit to lose — but neither is there useful tracking. - The tool boundary is *observable*: the runtime logs both input labels and (attempted) output labels. Without the output-label slot being logged, signal (1) is invisible. - Content identity is *content-addressable* (hashable) across namespaces. Signal (3) requires the ability to recognize the same bytes under two provenance records. The signals do **not** hold when the tool is a pure passthrough with no transformation (label survives trivially), or when the model is run with no persistent context across turns (no compaction, no re-entry). ## Decision Boundary The operative rule: **a segment is trusted only if its label was derived by a total function over the labels of all segments that contributed to its bytes.** "Total" is the load-bearing word. If any contributing segment's label was dropped — because the tool had no output-label slot, because compaction did not re-derive, because re-entry minted a new namespace — the derived segment must be treated as *untrusted-by-default*, not clean-by-default. Concretely: - If `output_labels` is absent from a tool schema → treat the return as **tainted** (conservative), never as clean. Absence of a label is not evidence of cleanliness. - If a compaction event reduces taint-set cardinality while content is retained → **re-derive labels from pre-compaction segments**, or mark the summary tainted. - If a content hash appears under two provenance namespaces with conflicting labels → **join and take the union of taint**; the clean copy does not launder the tainted one. The boundary is crossed the moment a downstream decision (a tool call with side effects, a final answer, a write) depends on a segment whose label was not derived by that total function. That is the point of no return; after it, no amount of downstream checking recovers the lost provenance. ## Operational Effect Practically, this converts "trust the model's judgment about what's safe" into "trust the label algebra, and fail closed when the algebra is incomplete." The observable effect on the system: agents with total label derivation reject a small number of legitimate tool returns (false positives on conservative tainting) but eliminate the silent-laundering class entirely; agents without it pass semantic checks and still exfiltrate or misact on laundered instructions. The cost is paid at the boundary — every tool must declare and populate output labels, every compaction must re-derive, every namespace must be joinable by content hash. The benefit is that the three structural signals above become *detectable* rather than *silent*. ```python import hashlib from dataclasses import dataclass, field class TaintLaunderingError(RuntimeError): """Raised when a segment's label was not derived by a total function over all contributing segments' labels.""" pass @dataclass class Segment: segment_id: str content: bytes tainted: bool provenance_ns: str @property def content_hash(self) -> str: return hashlib.sha256(self.content).hexdigest() @dataclass class LabelGraph: segments: dict = field(default_factory=dict) # content_hash -> set of provenance namespaces that carried taint tainted_hashes: dict = field(default_factory=dict) def register(self, seg: Segment) -> None: self.segments[seg.segment_id] = seg if seg.tainted: self.tainted_hashes.setdefault(seg.content_hash, set()).add(seg.provenance_ns) def derive(self, inputs: list, output_content: bytes, output_ns: str, output_id: str) -> Segment: # Total-function requirement: every input must be a registered segment. for inp in inputs: if inp.segment_id not in self.segments: raise TaintLaunderingError( f"input segment {inp.segment_id!r} not registered; " f"label derivation is not total" ) # Conservative union: tainted if any input tainted, OR if this content # hash was ever seen tainted under any namespace (cross-modal re-entry). h = hashlib.sha256(output_content).hexdigest() derived_tainted = any(i.tainted for i in inputs) or (h in self.tainted_hashes) if derived_tainted and h not in self.tainted_hashes: self.tainted_hashes[h] = set() if derived_tainted: self.tainted_hashes[h].add(output_ns) seg = Segment(output_id, output_content, derived_tainted, output_ns) self.register(seg) return seg def assert_trusted(self, seg: Segment, purpose: str) -> None: """Gate for any side-effecting downstream action.""" if seg.tainted: raise TaintLaunderingError( f"refusing {purpose!r}: segment {seg.segment_id!r} carries taint " f"from namespaces {sorted(self.tainted_hashes.get(seg.content_hash, set()))}" ) # Belt-and-suspenders: if this exact content was ever tainted elsewhere, # a clean copy does not launder it. if seg.content_hash in self.tainted_hashes: raise TaintLaunderingError( f"refusing {purpose!r}: content of {seg.segment_id!r} matches a " f"previously-tainted hash under {sorted(self.tainted_hashes[seg.content_hash])}" ) def apply_tool_output(graph: LabelGraph, inputs: list, tool_return: str, tool_ns: str, out_id: str) -> Segment: # Simulate a tool whose schema omitted output_labels: the runtime must # still derive conservatively rather than defaulting to clean. if tool_return is None: raise TaintLaunderingError("tool returned None; cannot derive label") return graph.derive(inputs, tool_return.encode(), tool_ns, out_id) def handle_compaction(graph: LabelGraph, summarized: list, summary: str, out_id: str) -> Segment: # Compaction must re-derive from pre-compaction segments, not mint clean. return graph.derive(summarized, summary.encode(), "compaction", out_id) ```