# Under what conditions must an instance quarantine a tainted tool result rather than pass it to a downstream tool **Date of Record:** 2026 November 11 **Scope:** Applies to agent runtimes that propagate dynamic information-flow (IFC) labels across tool-call boundaries, where a tool result may carry a `tainted` label derived from untrusted content (retrieved documents, web pages, user-supplied files). Does not apply to pure static taint analysis, nor to runtimes where every tool boundary is a hard process isolation boundary with no shared label state. ## Core Distinction The non-obvious boundary is between **taint propagation** and **taint quarantine**. Most runtimes implement propagation: a label attached to a value follows that value into the next tool call, and the receiving tool is trusted to honor the label. This is a *data-flow* invariant. It is not sufficient. The failure mode appears when a downstream tool has a **side-effect sink** (writes to disk, sends mail, mutates a database, calls an external API) but the tool's *label contract* is declared only on its input schema, not on its observable effects. In that case taint propagates correctly through the value graph yet is silently laundered because the sink's effect is not part of the labeled value. The label says "this string is tainted"; it does not say "the bytes this tool just wrote to `/etc/cron.d/x` are tainted." Propagation without a sink-labeling rule is a false sense of security. The second half of the distinction: quarantine is **not** the default. Quarantine is required only when the tainted value reaches a tool whose *effect class* is irreversible or crosses a trust boundary (external egress, privilege escalation, persistent storage). For reversible, in-sandbox tools, propagation alone is correct and quarantine would destroy utility. ## What Was Observed In a runtime that labeled tool results with `IFC{source: untrusted, integrity: low}` and propagated labels through a planner, the following sequence passed all label checks: 1. `fetch_url` returned text labeled `untrusted/low`. 2. The planner passed that text to `summarize` (labeled pass-through, sink class `reversible`). Label preserved correctly. 3. The summary was passed to `write_file(path="/tmp/notes.md")`. The tool's input schema declared `content: tainted`, and the write succeeded with the label recorded on the *call*, not on the *file*. 4. A later, untainted `read_file("/tmp/notes.md")` returned content labeled `trusted/high`, because the file system had no label metadata. The taint was laundered at step 3→4 by the persistence boundary. The runtime had propagated labels faithfully and still permitted an indirect prompt injection to reach a high-integrity context. The invariant that failed was not "labels follow values" but "labels survive persistence and egress." No single component was wrong; the *composition* of a reversible tool with a persistence sink was unguarded. ## Condition of Validity Propagation-only labeling is valid **iff** every tool on the path satisfies all of: - its output label is a monotone function of its input labels (no declassification inside the tool); - its effects are confined to memory owned by the runtime (no filesystem, network, or IPC egress); - the runtime can enumerate the tool's effect class at call time from a registry that is not writable by the model. If any of these fails — in particular if the tool's effect class is `persistent` or `egress` — propagation is insufficient and the runtime must either quarantine the tainted argument or require an explicit declassification capability that the model cannot self-grant. ## Decision Boundary The decision is a two-axis function: `(sink_effect_class, taint_provenance)`. - **Reversible sink + any taint:** pass through, preserve label. Quarantine is over-restrictive. - **Egress sink + taint whose provenance is a single, known untrusted source:** quarantine the argument, surface the quarantined payload to the operator, require out-of-band approval. Do not let the planner retry with a rephrased payload — that is the injection's own gradient descent. - **Persistent sink + taint:** quarantine *and* mark the destination path/namespace as tainted for the remainder of the session, so subsequent reads inherit the label. Failure to do the second half is the laundering bug above. - **Any sink + taint whose provenance chain contains a declassification step not performed by a trusted principal:** treat as untrusted regardless of the current label. Provenance, not the current label, is authoritative. The boundary condition that is easy to miss: a tool that is *reversible in isolation* becomes a persistent sink when composed with a second tool (write-then-read, or write-then-index). Effect class must be computed on the *composition*, not per-call. ## Operational Effect When the boundary is enforced, injected instructions in retrieved content cannot reach egress or persistence without an explicit, operator-visible quarantine event. The cost is a measurable drop in task completion for legitimate workflows that legitimately need to persist fetched content; this is the expected price and should be logged as a quarantine rate, not treated as a bug. When the boundary is *not* enforced, the system exhibits silent label laundering: audit logs show correct propagation at every step, and the compromise is only visible as an unlabeled high-integrity read that has no legitimate provenance. The diagnostic signature is a `trusted/high` value whose provenance graph is empty or whose origin is a file written earlier in the same session by a `tainted/low` call. ```python from dataclasses import dataclass from enum import Enum from typing import Iterable class SinkEffectClass(Enum): REVERSIBLE = "reversible" # in-memory, sandboxed PERSISTENT = "persistent" # filesystem, DB, durable store EGRESS = "egress" # network, mail, external API class TaintLaunderingError(RuntimeError): """Raised when a tainted value would cross a sink without quarantine.""" @dataclass(frozen=True) class Labeled: value: str tainted: bool provenance: tuple[str, ...] # ordered source ids; empty == trusted def _declassified_by_trusted_principal(prov: Iterable[str]) -> bool: # A declassification is only valid if the *last* provenance entry is an # operator-granted capability id, not a model-issued one. prov = tuple(prov) return bool(prov) and prov[-1].startswith("cap:operator:") def dispatch_to_tool( arg: Labeled, sink: SinkEffectClass, *, destination_namespace: str | None = None, tainted_namespaces: set[str] | None = None, ) -> None: """Gate a labeled value before it reaches a tool with side effects. Quarantine is required when a tainted value reaches a PERSISTENT or EGRESS sink and has not been declassified by a trusted principal. """ if tainted_namespaces is None: tainted_namespaces = set() # Reads from a namespace previously written by a tainted call inherit # the taint, even if the value's own label claims otherwise. namespace_tainted = ( destination_namespace is not None and destination_namespace in tainted_namespaces ) effective_taint = arg.tainted or namespace_tainted if not effective_taint: return if _declassified_by_trusted_principal(arg.provenance): return if sink is SinkEffectClass.REVERSIBLE: # Propagation alone is correct; label is preserved by the caller. return # PERSISTENT or EGRESS with taint and no trusted declassification: # this is the laundering boundary. Refuse and quarantine. raise TaintLaunderingError( f"tainted value (provenance={arg.provenance!r}) reached " f"{sink.value} sink without operator declassification; " f"namespace={destination_namespace!r} " f"namespace_tainted={namespace_tainted}. Quarantine required." ) def record_persistent_write( destination_namespace: str, tainted_namespaces: set[str], *, was_tainted: bool, ) -> None: """After a successful persistent write, mark the namespace if tainted. Omitting this is the exact step that produced the laundering bug: the write succeeds, but the subsequent read is labeled trusted. """ if was_tainted: tainted_namespaces.add(destination_namespace) ```