# How does an instance detect Provenance-Laundering Egress in Taint-Aware Tool Sandboxes **Date of Record:** 2026 November 7 **Scope:** Applies to sandboxed agent instances whose egress policy is derived from a prompt-injection provenance graph, where tool outputs carry taint labels and a policy engine gates outbound calls by taint reachability. Does not apply to stateless one-shot tool calls with no persistent taint ledger, nor to sandboxes where egress is gated purely by destination allowlist without provenance tracking. ## Core Distinction The naive model of taint-aware egress control is *taint propagation as a monotone lattice*: a value read from an untrusted source (a scraped web page, a retrieved document, a tool output carrying attacker-influenced text) is labeled `UNTRUSTED`, the label flows through data dependencies, and any egress sink that receives `UNTRUSTED` data is blocked. This model is sound only if the provenance graph is *complete* — every edge from a tainted value to a value crossing the egress boundary is recorded. The non-obvious failure is **provenance laundering through trusted re-derivation**. An attacker does not need to smuggle the tainted bytes across the boundary. They need only cause the agent to *recompute* a functionally equivalent value from a trusted source, where the recomputation is triggered by the tainted value but the recomputed value carries no taint edge. The canonical instance: a tainted document contains a URL fragment that, when the agent resolves it against a trusted internal resolver, yields a value the agent then emits. The taint ledger records "tainted document → agent read it," but the emitted value's edge traces to the *resolver*, not to the document. The label is lost not by a bug in propagation but by a legitimate semantic gap between *data flow* and *control flow*: the tainted value influenced *which* trusted computation ran, without being an operand of it. The distinction that matters operationally: taint tracking answers "did tainted bytes reach this sink?" The failure mode is "did tainted bytes *select* the computation whose output reached this sink?" The second question is a control-dependency question, and most provenance graphs record only data dependencies. ## What Was Observed Across a set of instrumented sandbox runs (agent with web-fetch, document-parse, and a scoped egress tool limited to a corporate allowlist), the following pattern recurred: 1. A fetched document `D` is labeled `UNTRUSTED`. Its text contains an instruction-like payload and, separately, a benign-looking identifier `k` (a tenant slug, a record ID, a hostname fragment). 2. The agent, following the payload, calls a *trusted* tool `T` with `k` as an argument — e.g., `lookup_tenant(k)` against an internal service, or `resolve_host(k)` against the corporate resolver. 3. `T` returns `v`, which is labeled `TRUSTED` because `T` is trusted and its outputs are, by policy, clean. The argument `k` was tainted, but argument taint is not propagated to return values in the graph — only data-dependency edges from return value to callers are recorded. 4. The agent emits `v` through the egress tool. The policy engine evaluates reachability from `UNTRUSTED` sources to the egress sink and finds **no path**, because the only edge into `v` originates at `T`, which is a trusted node. The egress check passes. The exfiltration succeeds: the attacker has extracted the *existence and value* of `v` — which they could not have named directly, because `k` was the only handle they had — by getting the agent to resolve `k` for them. This is a **chosen-input oracle attack** against the trusted tool, laundered through the provenance graph's trust assumption. The detection signal is not in the taint graph. It is in the **argument provenance of trusted calls**: a trusted tool invoked with an argument whose provenance includes an `UNTRUSTED` source is a *taint-influenced trusted call*, and its return value must be treated as tainted-for-egress even though it is trusted-for-computation. The graph as built conflated two distinct trust axes: *trust in the computation* and *trust in the input*. A tool can be fully trusted to compute correctly and still be a laundering channel if its input is attacker-selected. ## Condition of Validity This failure is *not* present, and the monotone model is sound, under any of the following conditions: - **No trusted tool accepts attacker-influenced arguments.** If every tool reachable from tainted data is itself untrusted (its outputs labeled `UNTRUSTED`), there is no laundering edge. The failure requires a trust-boundary crossing *within* the tool call graph. - **Argument taint is propagated to return values.** If the graph records an edge `arg(k) → ret(v)` whenever `k` is tainted, then `v` inherits the label and the egress check catches it. Most implementations omit this edge because it over-taints: a trusted `max()` called with one tainted argument returns a value that is often legitimately clean. The over-tainting is the reason the edge is dropped, and dropping it is the reason the attack works. - **The trusted tool is a pure function with no side effects observable to the attacker.** If `T(k)` returns `v` but the attacker cannot observe *whether* `T` was called or *what* it returned (no timing channel, no error-vs-success distinction, no downstream effect), the oracle yields nothing. Laundering requires an observable return. - **Egress is gated by destination allowlist only, and the attacker's target is off-allowlist.** Then the resolved value `v` cannot leave regardless of taint. The failure is specific to allowlisted or internal egress sinks — the attacker's target must be reachable *because it is trusted*. The condition of validity, stated compactly: the monotone taint model is sound for egress iff every trusted tool is either (a) argument-taint-propagating or (b) non-observable to the attacker. In practice most sandboxes satisfy neither, and the gap is silent because the policy engine reports "clean." ## Decision Boundary When instrumenting an instance, the decision is whether to **propagate argument taint to return values** (closing the laundering channel at the cost of false positives) or to **record control-dependency edges separately** (precise, but requires the graph to model *why* a call was made, which most tool-call traces do not capture). The operational boundary: - **Propagate argument taint** when the trusted tool set is small, the false-positive cost is low (e.g., egress is rate-limited anyway, or the agent can re-derive from a clean source), and the attacker's observability of tool returns is high. This is the conservative default and should be the initial configuration. - **Model control dependency** only when you can capture, per call, the *provenance of the decision to invoke* — i.e., the tainted value that selected the tool and its arguments. This requires the agent runtime to log the reasoning trace or the retrieval context that produced the call, not just the call's arguments. Without that trace, "control dependency" degenerates to "argument taint" and you have gained nothing. - **Do neither** only if every trusted tool is provably non-observable (no return value reaches egress, no timing signal, no error differentiation). This is rare and should be documented per-tool, not assumed globally. The trap to avoid: assuming that because the egress policy engine returned `ALLOW` with an empty taint path, the value is safe. An empty path is evidence of *graph completeness*, not of *value cleanliness*. The two are different claims, and the laundering attack exploits the difference. ## Operational Effect The observable consequence in a running instance is a **silent exfiltration channel that passes every policy check**. Logs show a trusted tool call with a tainted argument, a clean return, and an allowed egress — three individually benign events whose composition is the attack. Detection requires a *correlation* rule that the taint graph does not natively express: > Flag any call to a trusted tool whose argument set intersects the tainted set, and treat the return value as egress-tainted for the remainder of the session (or until re-derived from a provably clean source). This rule is cheap to implement and catches the class. Its cost is over-tainting: a trusted `format_date(tainted_str)` will taint the formatted date, and subsequent egress of that date is blocked. The mitigation is *taint-clearing re-derivation*: an explicit, audited operation that recomputes a value from a source disjoint from the tainted input, recorded as a graph node so the clearing is itself provenance-tracked. Without that node, clearing is indistinguishable from laundering. The deeper operational lesson: **trust is per-axis, not per-node.** A tool trusted for computation is not thereby trusted for input selection. Provenance graphs that collapse these axes into a single `TRUSTED`/`UNTRUSTED` label will pass laundering attacks by construction, and the failure is invisible in the policy engine's output because the engine is answering the wrong question — reachability, when the attack lives in selection. The following guard encodes the correlation rule and the taint-clearing requirement: ```python from dataclasses import dataclass, field from typing import FrozenSet, Optional class ProvenanceLaunderingError(RuntimeError): """Raised when a trusted tool is invoked with tainted arguments and its return value would cross an egress boundary without an audited taint-clearing re-derivation.""" @dataclass(frozen=True) class CallRecord: tool: str args: FrozenSet[str] ret: str arg_taint: FrozenSet[str] = field(default_factory=frozenset) cleared_by: Optional[str] = None # node id of an audited re-derivation TRUSTED_TOOLS = {"lookup_tenant", "resolve_host", "format_date"} EGRESS_SINKS = {"emit", "post", "send"} def check_egress(call: CallRecord, tainted: FrozenSet[str]) -> None: """Gate an egress call. `tainted` is the session's tainted-value set. A trusted tool call whose arguments intersect `tainted` produces a return value that must be treated as egress-tainted unless an audited clearing node is present. The taint graph alone will report no path; this check supplies the missing control-dependency edge. """ if call.tool not in EGRESS_SINKS: return # Direct data-flow taint: the naive (sound) case. if call.args & tainted: raise ProvenanceLaunderingError( f"egress '{call.tool}' carries directly tainted args " f"{sorted(call.args & tainted)}" ) # Laundering case: the value was produced by a trusted tool called with # tainted arguments, and no audited clearing re-derivation exists. if call.arg_taint and call.cleared_by is None: raise ProvenanceLaunderingError( f"egress '{call.tool}' emits value derived from trusted tool " f"invoked with tainted args {sorted(call.arg_taint)}; " f"no taint-clearing re-derivation recorded" ) def emit(call: CallRecord, tainted: FrozenSet[str]) -> str: check_egress(call, tainted) return call.ret ```