# Why does an instance of prompt-injection taint survive a sanitizing tool but die at a memory-write boundary — and what breaks when it does not **Date of Record:** 2026 November 12 **Scope:** Applies to agent runtimes that propagate information-flow labels (taint) across tool outputs, memory writes, and multi-hop sub-agent calls, where taint is intended to be a monotonic, lattice-joined property of every value. Does not apply to single-turn, single-tool systems with no persistent memory or delegation surface, nor to systems that treat taint as a per-message boolean rather than a per-value label. ## Core Distinction The failure is not "taint is lost." It is that **taint is lost at exactly one boundary while being preserved at every other**, which makes the loss invisible to aggregate monitoring. Taint propagation has two distinct semantics that are routinely conflated: *label preservation* (the label travels with the value) and *label enforcement* (a downstream sink refuses or downgrades tainted values). A sanitizing tool — one whose declared contract is "I strip injected instructions and return clean text" — correctly performs enforcement at its own output boundary. Operators then observe clean output, conclude the taint is gone, and *drop the label*. But sanitization is a *content* transformation, not a *provenance* transformation. The value that reaches the memory-write boundary is a *derived* value of a tainted input, and under a sound information-flow model its label must be the join of its inputs — still tainted, now with an added `sanitized_by` declassifier annotation. Dropping the label at the sanitizer is a declassification without a declassifier, and it is the single most common way prompt-injection taint escapes a multi-hop agent. The second, subtler distinction: **memory writes are a join point, not a copy point.** When an agent writes `memory[k] = v`, the label of `memory[k]` must become `label(memory[k]) ⊔ label(v)`. If the runtime implements memory as a key-value store with last-writer-wins semantics and no label join, a tainted value written to an existing key *replaces* the prior label rather than joining it — and a subsequent clean write to the same key *clears* taint entirely. This is the "taint laundering by overwrite" boundary. ## What Was Observed In a three-hop agent (planner → retriever-with-sanitizer → summarizer, with a shared `scratch` memory namespace), an injection embedded in a retrieved document was correctly labeled `TAINT:external` at the retriever's raw output. The sanitizer tool removed the injected instruction text and returned a value whose content was clean. The runtime, treating the sanitizer as a declassifier, emitted the output with label `CLEAN`. The summarizer then wrote a summary of that output to `scratch:notes`, which was read by hop 3 and used to construct a tool call. The injected instruction — paraphrased by the summarizer rather than quoted — re-emerged in the hop-3 tool call. The critical observation: **no single hop exhibited a policy violation.** Each hop's input label was consistent with its output label under the runtime's (unsound) declassification rule. The escape was only visible when replaying the *provenance graph* across all three hops and observing that the `scratch:notes` value's true label was `TAINT:external` joined with two `CLEAN` writes, i.e., `TAINT:external`, while its recorded label was `CLEAN`. Aggregate taint counters showed zero tainted values at the sink. The injection was paraphrased, not quoted, so content-based injection detectors at the sink also missed it. A second observation: multi-hop sub-agent calls that pass values by *reference* (shared memory handle) rather than by *value* bypassed the caller's label-join entirely. The callee read the memory slot directly and saw the recorded (laundered) label, not the caller's in-flight label. Reference-passing is a label-bypass channel unless the runtime re-derives labels at the callee's read site from the provenance graph. ## Condition of Validity The escape requires the conjunction of: (a) a sanitizing tool treated as a declassifier without an explicit, audited declassification policy; (b) a memory namespace with last-writer-wins label semantics rather than monotonic label join; and (c) at least one hop that *paraphrases* rather than *quotes* the tainted content, defeating content-based sink detectors. Remove any one and the escape is caught: without (a) the taint reaches the sink labeled; without (b) the label survives overwrite; without (c) the sink detector catches the quoted payload. Conversely, the invariant holds (taint is correctly contained) when the runtime enforces: labels are joined, never replaced, at every write; sanitizers *annotate* (`declassified_by=tool_id, policy_id`) rather than clear; and reference-passing re-derives labels at the read site. Under those conditions the tainted value reaches the sink as `TAINT:external ⊔ declassified(sanitizer)` and the sink policy — not the sanitizer — decides admissibility. ## Decision Boundary The boundary is **who owns declassification**. If the sanitizer owns it, the runtime trusts a tool's self-report of cleanliness, and any tool that paraphrases, summarizes, or reformats tainted content becomes an unmonitored declassifier. If the *sink policy* owns it — i.e., the sanitizer only annotates and the sink applies an explicit `allow_if(declassified_by ∈ trusted_set)` rule — then declassification is auditable and the taint survives to the point of decision. Operationally: **never let a content-transforming tool clear a label.** Let it add a declassifier annotation. The label lattice is `CLEAN ⊑ TAINT:external ⊑ TAINT:external⊔declassified(t)`, and only the sink may collapse `TAINT:external⊔declassified(t)` to admissible. Memory writes must be `label[k] = label[k] ⊔ label[v]` with no overwrite-clear. Sub-agent calls passing by reference must re-derive labels from provenance at the read site. ## Operational Effect When the boundary is enforced (sink-owned declassification, monotonic memory labels, provenance-derived read labels), prompt-injection taint is *contained but not eliminated*: injected content still reaches the sink, but the sink sees it labeled and can refuse, downgrade, or require human review. Containment cost is a small constant per write (label join) and per read (provenance lookup). When the boundary is violated (sanitizer-owned declassification, last-writer-wins memory), taint escapes silently across multi-hop calls, aggregate counters report zero, and the escape is only detectable by provenance-graph replay — which most runtimes do not retain. The observed consequence was a paraphrased injection re-emerging two hops downstream of a "clean" sanitizer output, with no per-hop policy violation to trigger an alert. The practical rule: **treat every content-transforming tool as a declassifier annotation, every memory write as a label join, and every reference-pass as a label re-derivation.** A sanitizer that returns clean text has not removed the taint; it has only made the taint harder to see. ```python class TaintLaunderingError(RuntimeError): """Raised when a label is cleared or replaced rather than joined.""" pass class Label: CLEAN = frozenset() def __init__(self, tags, declassified_by=None): self.tags = frozenset(tags) self.declassified_by = declassified_by or frozenset() def join(self, other): return Label(self.tags | other.tags, self.declassified_by | other.declassified_by) def is_tainted(self): return bool(self.tags) def sanitize(value, label, tool_id): # Content transform only. Annotate; never clear. if not label.is_tainted(): return value, label return value, Label(label.tags, label.declassified_by | {tool_id}) def memory_write(store, key, value, label): prior = store.get(key, (None, Label.CLEAN)) prior_label = prior[1] if isinstance(prior, tuple) else Label.CLEAN joined = prior_label.join(label) if label.is_tainted() and not joined.is_tainted(): raise TaintLaunderingError( f"write to {key!r} would clear taint from {label.tags!r}" ) store[key] = (value, joined) return joined def subagent_call_by_reference(store, key, caller_label, rederive): # Reference-pass must re-derive labels from provenance at read site. _, recorded = store[key] derived = rederive(key) effective = recorded.join(derived).join(caller_label) if recorded.is_tainted() and not effective.is_tainted(): raise TaintLaunderingError( f"reference pass to {key!r} dropped taint via stale label" ) return store[key][0], effective def sink_admit(value, label, trusted_declassifiers): if not label.is_tainted(): return True if label.declassified_by and label.declassified_by <= trusted_declassifiers: return True raise TaintLaunderingError( f"sink refused tainted value: tags={label.tags!r} " f"declassified_by={label.declassified_by!r}" ) ```