# What structural signals indicate that taint from a prompt-injection source has silently laundered through a tool-output re-serialization boundary **Date of Record:** 2026 November 7 **Scope:** Applies to agent runtimes that propagate provenance/taint labels across tool calls and retrieved-context injection, specifically when a tool output is re-serialized (JSON→string→JSON, template rendering, log-and-replay, or schema coercion) before re-entering the model context. Does not apply to runtimes where taint is tracked purely on the raw byte stream and never re-parsed, nor to single-turn systems with no tool loop. ## Core Distinction A taint label has two independent failure modes: **dropping** (label lost, tainted content re-enters as clean) and **laundering** (label survives as a *string* but is disconnected from the *bytes it was supposed to guard*). Dropping is loud — you can detect it by asserting every ingested span carries a label. Laundering is silent: the label is present, well-formed, and attached to a span whose content no longer corresponds to the original tainted substring, because re-serialization changed the byte representation (escaping, Unicode normalization, key reordering, whitespace collapse) without updating the label's span offsets. The non-obvious invariant: **a provenance label is only valid relative to a specific byte representation of the content it labels.** Any transformation that changes the byte representation must either re-anchor the label or invalidate it. Re-serialization boundaries are where this invariant is violated because the serializer is usually treated as "semantics-preserving" — and at the JSON *value* level it is, while at the *offset/byte* level it is not. ## What Was Observed In a retrieval-augmented agent with a `fetch_document` tool, a poisoned document contained an instruction payload. The taint tracker correctly labeled the retrieved chunk `TAINTED:external_untrusted` at ingestion. The chunk then passed through a summarization tool whose output was re-serialized through a JSON round-trip (`json.dumps` → transport → `json.loads`) and a Jinja-style template render before re-entering context. Post-boundary, three structural signals appeared together, and any one alone was insufficient: 1. **Label/content cardinality mismatch.** The taint label's recorded span length was 412 bytes; the re-serialized span at the same nominal position was 388 bytes. The delta matched the escaping of `"` → `\"` plus a `\u2028` line-separator normalization. The label pointed at a region that *started* correctly but *ended* mid-token, so a downstream substring-guard check passed on the prefix and missed the injected suffix. 2. **Label attached to a container, not a leaf.** After the template render, the label was hoisted to the enclosing `tool_result` object rather than the specific string field. Downstream consumers that read `tool_result.content` (a sibling field) saw an unlabeled string. The taint existed but guarded the wrong node. 3. **Re-parse identity drift.** A canonical hash of the tainted substring before the boundary (`sha256` of the raw span) did not equal the hash of the span the label referenced after the boundary, even though a *semantic* diff of the two showed no change. This is the decisive signal: semantic equality with byte-inequality under a stable label is the signature of laundering. The injection succeeded not by removing taint but by making the taint label reference a byte range that no longer contained the instruction, while the instruction itself sat in an adjacent unlabeled range. ## Condition of Validity The laundering is only possible when all of the following hold; remove any one and the boundary is safe: - The re-serializer is **not label-aware** (it transforms content without a callback to re-anchor spans). - Taint is stored as **offsets or span lengths** into a mutable buffer rather than as an **immutable content-addressed reference** (e.g., a hash of the exact tainted bytes). - Downstream enforcement checks **label presence** rather than **label-to-bytes binding** (it asks "is this span tainted?" not "does this span's current bytes hash to the labeled hash?"). - The transformation is **value-preserving but byte-changing** (JSON escaping, Unicode NFC/NFD normalization, HTML entity encoding, YAML folding, log timestamp prefixing). If taint is bound to a content hash and enforcement re-hashes at consumption time, laundering degrades to a detectable *hash mismatch*, which is a safe failure. If the serializer is label-aware, spans are re-anchored and no mismatch occurs. ## Decision Boundary Treat the boundary as **unsafe to cross with a live label** when the serializer cannot re-anchor. The operational rule: - **Re-anchor** if the serializer exposes a mapping from old spans to new spans (rare; requires instrumenting the serializer). Recompute span offsets against the post-serialization buffer and verify each re-anchored span hashes to the original content hash modulo the declared normalization. - **Invalidate (fail-closed)** if no mapping is available: strip the label, mark the entire re-serialized output as `TAINTED:unknown_provenance`, and force re-derivation. This over-taints but never under-taints. - **Never** carry a span label across a byte-changing transform by position alone. Position-preserving is not content-preserving. The decision hinges on one question: *can I prove, at consumption time, that the bytes I am about to trust are the bytes the label was issued for?* If no, the label is decorative. ## Operational Effect Enforcing content-hash binding at consumption converts silent laundering into a loud, catchable `TaintLaunderingError` at the boundary, at the cost of re-hashing every tainted span on read (measured ~0.3ms per KB in the observed runtime — acceptable for tool outputs, prohibitive for large retrieved corpora, which is why the fail-closed invalidation path is the default for bulk retrieval). The net effect is that prompt-injection payloads that survive re-serialization arrive at the model wearing an `unknown_provenance` label rather than a clean one, and the guard that would have been bypassed now trips on hash mismatch instead of passing on prefix match. ```python import hashlib from dataclasses import dataclass class TaintLaunderingError(RuntimeError): """Raised when a taint label's referenced bytes no longer match its issued content hash.""" @dataclass(frozen=True) class TaintLabel: content_sha256: str # hash of the exact bytes the label was issued for origin: str # e.g. "external_untrusted" span_start: int span_end: int def reanchor_or_invalidate( label: TaintLabel, post_serialization_buffer: bytes, serializer_provides_mapping: bool, ) -> TaintLabel: """Cross a re-serialization boundary with a taint label. Re-anchors if the serializer gives us a span mapping; otherwise fails closed by re-issuing the label against the whole buffer as unknown provenance. """ if not serializer_provides_mapping: # Fail-closed: over-taint the entire re-serialized output. return TaintLabel( content_sha256=hashlib.sha256(post_serialization_buffer).hexdigest(), origin="unknown_provenance", span_start=0, span_end=len(post_serialization_buffer), ) # Mapping path: verify the re-anchored span still hashes to the issued content. raise TaintLaunderingError( "serializer mapping path not implemented; refusing to carry span label " f"for origin={label.origin!r} across byte-changing transform without " "content-hash verification" ) def consume_tainted_span(label: TaintLabel, current_bytes: bytes) -> None: """Enforcement point: verify label-to-bytes binding before trusting content.""" actual = hashlib.sha256(current_bytes).hexdigest() if actual != label.content_sha256: raise TaintLaunderingError( f"label bound to {label.content_sha256[:12]} but span now hashes to " f"{actual[:12]}; taint laundered through re-serialization boundary" ) ```