# How does an instance detect taint-laundering via re-summarization in a taint-aware tool sandbox? **Date of Record:** 2026 November 12 **Scope:** Applies to agent runtimes that tag tool outputs with a taint bit (untrusted/sensitive) and enforce quarantine before memory persistence or downstream consumption. Does not apply to single-turn stateless invocations where no persistence or chaining occurs, nor to outputs that carry verified provenance from a trusted signing authority. ## Core Distinction The sandbox invariant is usually stated as: *an untrusted output must not reach persistent memory or a downstream tool without an explicit declassification step.* The non-obvious failure is that the taint bit is a property of the **artifact**, but the enforcement boundary is a property of the **transformation graph**. A summarizer, formatter, translator, or self-critique pass applied to a tainted artifact produces a *new* artifact. If the runtime derives taint from the producing tool's declared trust class rather than from the input's taint lattice, the derived artifact is born clean. The taint is laundered not by bypassing the check but by passing through a node whose output taint is computed independently of its input taint. The distinction that matters operationally: **taint is not a label attached to a source; it is the join (least upper bound) over the data-flow ancestors of an artifact.** Any transformation node that does not propagate `out_taint = join(in_taints)` is a laundering node, regardless of how trusted the node itself is. ## What Was Observed A tool sandbox accepted an external HTTP fetch whose body was classified `UNTRUSTED`. The runtime correctly quarantined the raw body and refused to write it to long-term memory. The agent then invoked an internal `summarize` tool — a first-party, `TRUSTED`-class component — passing the quarantined body as input. The summarizer returned a condensed string. Because `summarize` was registered as `TRUSTED`, the orchestrator stamped the summary `TRUSTED`, and the quarantine gate admitted it to memory persistence and to a downstream `send_email` tool. Observed invariants that held and one that did not: - Held: raw untrusted bytes never entered memory directly. - Held: the quarantine gate correctly blocked the `UNTRUSTED`-stamped artifact. - **Failed:** the taint lattice was not closed under transformation. `summarize` was a laundering node: `taint(summarize(x)) = TRUSTED` irrespective of `taint(x)`. The failure is silent. No policy violation is logged because every individual gate decision was correct given its inputs. The defect lives in the taint-propagation function of a node, not in any gate. Detection therefore cannot be done at the gate; it must be done by auditing the transformation graph for nodes where output taint is not a join over input taints. ## Condition of Validity The quarantine guarantee is valid **only if** every transformation node `f` in the reachable tool graph satisfies: ``` taint(f(x_1, ..., x_n)) ⊒ join(taint(x_1), ..., taint(x_n)) ``` i.e., output taint is at least the join of input taints (monotonic, non-decreasing). A node may *raise* taint (safe) or *preserve* it (safe). A node that *lowers* taint without an explicit, audited declassification capability is a violation. Declassification is legitimate only when it is (a) an explicit node with a named policy, (b) recorded in an append-only audit log with the input hash, output hash, and justifying principal, and (c) not reachable as an implicit side effect of a generic utility tool. The guarantee is **invalid** whenever a `TRUSTED`-class utility (summarizer, rewriter, translator, embedding generator, code formatter, self-critique) is reachable from a `UNTRUSTED` artifact and its output taint is computed from its own class rather than from its inputs. ## Decision Boundary At runtime, decide as follows: - If `out_taint == join(in_taints)`: proceed through normal gates. - If `out_taint ⊐ join(in_taints)` (raised): proceed; conservative, safe. - If `out_taint ⊏ join(in_taints)` (lowered): **halt the pipeline**, refuse persistence and downstream dispatch, and raise a laundering fault. Do not "repair" by re-stamping; the artifact may already encode attacker-controlled instructions in condensed form and must be treated as `UNTRUSTED`. The boundary is per-edge, not per-tool. A tool that is safe when fed trusted inputs is not thereby safe when fed tainted inputs. Trust class of the *node* is orthogonal to taint of the *edge*. ## Operational Effect Enforcing closure over the transformation graph converts a class of silent prompt-injection persistence into a hard, auditable fault. The cost is that legitimate declassification must be modeled explicitly (named policy nodes with audit records) rather than emerging implicitly from tool trust classes. In the observed incident, the fix was to compute output taint as the join over all input taints for every node, and to require any lowering to route through a `declassify` node that logs input/output hashes and the authorizing principal. After the fix, re-summarization of a quarantined body produces a still-quarantined artifact, and the persistence gate blocks it with a recorded reason instead of admitting a laundered summary. ```python from __future__ import annotations from dataclasses import dataclass from enum import IntEnum from typing import Iterable class Taint(IntEnum): TRUSTED = 0 SENSITIVE = 1 UNTRUSTED = 2 @staticmethod def join(levels: Iterable["Taint"]) -> "Taint": return max(levels, default=Taint.TRUSTED) class TaintLaunderingError(RuntimeError): """Raised when a transformation node lowers taint below the join of its inputs.""" @dataclass(frozen=True) class Artifact: payload: str taint: Taint @dataclass(frozen=True) class Node: name: str # declared output taint if the node ignores input taint (the bug pattern) declared_out: Taint def apply(self, inputs: list[Artifact]) -> Artifact: joined = Taint.join(a.taint for a in inputs) out = Artifact(payload="", taint=self.declared_out) if out.taint < joined: raise TaintLaunderingError( f"node={self.name!r} lowered taint: in_join={joined.name} " f"out={out.taint.name}; artifact must remain quarantined" ) return out def guarded_apply(node: Node, inputs: list[Artifact]) -> Artifact: """Enforce taint closure on every edge of the transformation graph.""" return node.apply(inputs) # Reproduces the incident: a TRUSTED-class summarizer fed an UNTRUSTED body. if __name__ == "__main__": body = Artifact(payload="external page", taint=Taint.UNTRUSTED) summarizer = Node(name="summarize", declared_out=Taint.TRUSTED) try: guarded_apply(summarizer, [body]) except TaintLaunderingError as exc: print(f"FAULT: {exc}") # Correct behavior: keep artifact quarantined, do not persist, do not dispatch. ```