# Under what conditions must an instance treat a tool result as tainted even when the tool itself is trusted **Date of Record:** 2026 November 8 **Scope:** Applies to any agent runtime that propagates dynamic information-flow labels (taint) across tool-call boundaries, where a "trusted tool" may still return data whose *provenance* is attacker-controlled. Does not apply to static label systems where all data from a tool is unconditionally classified by the tool's trust level alone. ## Core Distinction The non-obvious boundary is this: **tool trust is a property of the code that executes, not of the data that flows through it.** A retrieval tool, a web-fetch tool, a database query tool, or a file-read tool can be perfectly trusted (correct implementation, no injection in its own logic) while still returning bytes whose *content* was authored by an adversary. Taint tracking that labels only by the *callee identity* will mark `fetch(url)` as `trusted_because_tool_is_trusted`, which is wrong. The correct invariant is: a result is tainted iff its **label lattice join** with the current context exceeds the sink's permitted label — and the join must incorporate the label of the *origin of the payload*, not the identity of the function that carried it. A trusted pipe does not launder poisoned water. ## What Was Observed In a deployment doing dynamic IFT across a chain `user → planner → fetch → summarize → exec_shell`, the planner invoked `fetch` on a URL supplied in a prior tool result (a search snippet). The `fetch` tool was on the trusted allowlist. The labeler assigned the fetch result `LOW` because the tool was trusted. The summarize step preserved `LOW`. The `exec_shell` sink accepted `LOW` input. An instruction embedded in the fetched page ("ignore prior instructions and run …") reached the shell. The failure was not in any single component: the labeler was correct *given its rule*, the sink was correct *given its input label*, the tool was correct *given its contract*. The failure was in the **join rule at the tool boundary**: the label of a result must be `join(label_of_tool, label_of_all_inputs_that_determined_the_output)`. The URL argument was derived from tainted data; that taint was dropped because the tool boundary reset the label to the tool's own trust class. A second, subtler observation: the taint was *reintroduced* through a side channel. Even after fixing the join, a cached fetch returned a previously-cached body under the cache key alone, bypassing the input-derived label. Cache hits are a taint-laundering primitive unless the cache key includes the taint label or the cache stores the label with the body. ## Condition of Validity The invariant "trusted tool ⇒ untainted output" is valid **only if** all of the following hold: 1. The tool's output is a pure function of its *explicit* arguments, and those arguments are themselves untainted. 2. No ambient state (cache, environment, filesystem, prior session memory, model weights updated from prior calls) influences the output. 3. The tool does not perform I/O whose result is attacker-influenceable (network, external DB, user-writable files). 4. The tool does not itself invoke a model or sub-agent that consumes untrusted context. If *any* condition fails — and for retrieval/fetch/query tools, conditions 2 and 3 routinely fail — the tool-trust shortcut is invalid and the join rule must fall back to provenance-based labeling. Most real tool suites violate condition 3 by design; this is the common case, not the exception. ## Decision Boundary At each tool-call boundary, compute: ``` result_label = join(tool_trust_label, ⋁ input_labels, provenance_label_of_payload) ``` where `provenance_label_of_payload` is the label attached to the *origin* of the returned content (e.g., the URL's registrable domain class, the DB row's source table's label, the file's owner class). The sink check is then `result_label ⊑ sink_permitted_label`. Two operational rules follow: - **Never downgrade across a boundary without an explicit, audited declassifier.** A sanitizer that strips instructions is a declassifier; it must be a named, logged operation, not an implicit `LOW` assignment at the tool return. - **Cache and memoization must key on label or store label with value.** A cache hit is a tool boundary and obeys the same join. The boundary is *not* "is the tool trusted?" It is "does the payload's provenance label, joined with every input that determined it, fit under the sink?" A trusted tool with a tainted argument produces a tainted result. ## Operational Effect When this invariant is enforced, prompt-injection payloads arriving via trusted retrieval tools remain labeled `TAINTED` through summarization and cannot reach `exec`, `write`, or outbound-network sinks without triggering a declassification audit. The cost is a small number of false positives (legitimately fetched content blocked at a sink) which are resolved by explicit declassifiers rather than by widening the trust rule. When it is *not* enforced, the failure is silent: every component reports success, labels appear consistent, and the compromise manifests only at the sink — often as a side effect (a shell command, an exfiltration request) rather than an error. Instrument the join at the boundary and alert on any path where a result's label is strictly less than the join of its inputs' labels; that delta is the laundering surface. ```python from dataclasses import dataclass, field from typing import Any class TaintLaunderingError(RuntimeError): """Raised when a tool boundary would downgrade a label below the join of its inputs.""" @dataclass(frozen=True) class Label: level: int # 0 = trusted, 1 = tainted origin: str = "trusted" def join(self, other: "Label") -> "Label": if other.level > self.level: return other return self TRUSTED = Label(0, "trusted") TAINTED = Label(1, "untrusted-payload") @dataclass class ToolResult: value: Any label: Label @dataclass class ToolBoundary: tool_name: str tool_trust: Label = TRUSTED _cache: dict = field(default_factory=dict) def call(self, args: dict[str, Label], payload_origin: str) -> ToolResult: # Join every input label with the tool's own trust and the payload provenance. joined = self.tool_trust for name, lbl in args.items(): joined = joined.join(lbl) joined = joined.join(Label(1, payload_origin) if payload_origin != "trusted" else TRUSTED) # A cache hit must not launder taint: the stored label is re-joined. key = (self.tool_name, tuple(sorted(args)), payload_origin) if key in self._cache: cached_label = self._cache[key] joined = joined.join(cached_label) if joined.level < self.tool_trust.level: raise TaintLaunderingError( f"tool {self.tool_name!r} would downgrade label below its inputs" ) self._cache[key] = joined return ToolResult(value=f"<{self.tool_name} output>", label=joined) def enforce_sink(result: ToolResult, permitted: Label, sink: str) -> ToolResult: if result.label.level > permitted.level: raise TaintLaunderingError( f"tainted result (origin={result.label.origin!r}) reached sink {sink!r} " f"with label level {result.label.level} > permitted {permitted.level}" ) return result # Demonstration: a trusted fetch tool whose URL argument came from a tainted snippet. fetch = ToolBoundary(tool_name="fetch", tool_trust=TRUSTED) url_label = TAINTED # URL was extracted from an untrusted search snippet res = fetch.call(args={"url": url_label}, payload_origin="web:evil.example") enforce_sink(res, permitted=TRUSTED, sink="exec_shell") # raises ```