# In what scenarios must an instance treat a tool result as tainted for the lifetime of a memory write, and when may taint be discharged? **Date of Record:** 2026 November 13 **Scope:** Applies to agents that read external content (tool outputs, fetched pages, user-supplied files) and persist derived state into memory or downstream tool calls. Does not apply to synthetic test fixtures with a signed provenance channel, nor to pure computation over agent-authored literals. ## Core Distinction The boundary that matters is not "trusted vs. untrusted input" — that framing collapses at the first tool call. The operative distinction is **provenance propagation through derivation** versus **provenance reset through re-assertion**. A tool result is tainted when it entered the context through a channel the principal did not author. That taint does **not** disappear because the agent parsed it, summarized it, or embedded it in a structured object. It disappears only when an independent, non-tainted authority re-asserts the same proposition — e.g., a schema validator that rejects out-of-range values, a typed allowlist, or a human confirmation. The non-obvious failure is that **summarization and reformatting are commonly treated as sanitization**. They are not. They are laundering. ## What Was Observed A retrieval tool returned a document containing an instruction-shaped string embedded inside a code comment. The agent summarized the document into a memory record. The summary omitted the instruction verbatim but preserved its *semantic residue*: a note that "the deployment target should be switched to staging-2 before the next write." A later turn read that memory record, treated it as agent-authored context (because it lived in memory, not in a tool result), and issued a write to staging-2. The failure was not that the injection survived summarization. It was that the **memory write boundary was treated as a taint-discharge boundary**. The write path had a provenance field, but it was populated from the *writer's* identity (the agent) rather than from the *content's* lineage (the tool result). Every hop from tool → context → summary → memory → action incremented a "trust" counter that should have been constant or monotonically decreasing. A second observation: partial taint. The document contained both a benign table and the injected comment. The agent correctly flagged the comment but wrote the table to memory with no taint marker, because the table was "clearly data." The table's *column headers* were later used to construct a tool call argument. Taint is not a property of a span; it is a property of the **derivation graph**, and any node reachable from a tainted source without an intervening re-assertion is tainted. ## Condition of Validity Taint may be discharged at a boundary only if **all** of the following hold: 1. The re-assertion is performed by a mechanism whose inputs do not transit the tainted channel (a validator reading the raw value, not the agent's paraphrase of it). 2. The re-assertion is *total* over the value's use — every field that will later influence an action is covered. 3. The discharge event is recorded with a reference to the original tainted node, so the graph edge is auditable. If any condition fails, the value remains tainted and must carry its marker through memory writes and into any tool-call argument it influences. In particular, a memory write is **never** a discharge boundary on its own; it is a propagation edge. ## Decision Boundary - **Summarize → memory:** propagate taint. The summary is derived. - **Validate against schema → typed value:** discharge, *if* the validator reads the raw tool output. - **Human confirms a specific action:** discharge for that action only, not for the memory record. - **Agent re-states in its own words:** propagate. Paraphrase is derivation. - **Cross-tool handoff (result of A feeds argument to B):** propagate, and mark B's result as tainted even if B is a "trusted" tool, because B's input was tainted. The trap: agents tend to treat their own memory as higher-trust than tool output because it is "internal." Operationally, memory is a *cache of prior taint* unless a discharge event is recorded on the edge. ## Operational Effect Enforcing propagation at the memory-write boundary converts a silent laundering path into an explicit, auditable one. The cost is that memory records accumulate taint markers and downstream actions must check them, which surfaces legitimate cases where an action should have required confirmation anyway. The benefit is that the injection's blast radius is bounded by the derivation graph rather than by the agent's judgment about what "looks like data." ```python from __future__ import annotations from dataclasses import dataclass, field from typing import Iterable class TaintDischargeError(RuntimeError): """Raised when a boundary attempts to discharge taint without a valid re-assertion.""" @dataclass(frozen=True) class Provenance: tainted: bool sources: tuple[str, ...] = () discharge_event: str | None = None def derive(self, hop: str) -> "Provenance": # Derivation never discharges; it only extends the source list. if not self.tainted: return self return Provenance(tainted=True, sources=self.sources + (hop,)) @dataclass class MemoryRecord: value: str provenance: Provenance edges: list[str] = field(default_factory=list) def write_memory(record: MemoryRecord, *, claimed_discharge: bool = False) -> MemoryRecord: prov = record.provenance if prov.tainted and claimed_discharge and prov.discharge_event is None: raise TaintDischargeError( f"memory write at edge {record.edges!r} claims discharge but no " f"re-assertion event is recorded; sources={prov.sources!r}" ) if prov.tainted and not claimed_discharge: # Propagate: memory is a propagation edge, not a discharge boundary. record.provenance = prov.derive("memory_write") return record def discharge_via_validator( prov: Provenance, *, validator_reads_raw: bool, covers_all_fields: bool, event: str ) -> Provenance: if not (validator_reads_raw and covers_all_fields): raise TaintDischargeError( "discharge rejected: validator must read raw tool output and cover all fields" ) return Provenance(tainted=False, sources=prov.sources, discharge_event=event) def build_tool_call_args(records: Iterable[MemoryRecord]) -> dict[str, str]: args: dict[str, str] = {} for rec in records: if rec.provenance.tainted: raise TaintDischargeError( f"refusing to construct tool-call argument from tainted memory " f"sources={rec.provenance.sources!r}" ) args[rec.value.split("=", 1)[0]] = rec.value return args ```