# Which invariants prevent privilege escalation when tool-call gating is taint-aware **Date of Record:** 2026 November 7 **Scope:** Applies to agentic runtimes that propagate data-flow taint labels across tool-call boundaries and gate privileged tool invocations on the *effective* taint of the calling agent's context. Does not apply to stateless per-call authorization models where each tool call is evaluated against the principal's static capability set with no cross-turn data-flow state. ## Core Distinction The non-obvious failure is the conflation of **taint of the arguments** with **taint of the authority that produced them**. A taint-aware gate that inspects only the payload of a privileged call (e.g., "is this `file_path` argument derived from untrusted content?") is structurally insufficient. The invariant that actually prevents privilege escalation is that **the gate must evaluate the taint closure of the entire provenance chain that selected the tool, chose the arguments, and decided to invoke it — including the reasoning context that the model read between the untrusted input and the call.** Attackers do not need to smuggle malicious bytes into the argument. They need only cause the *decision to call* the privileged tool to be influenced by untrusted content. A `read_file("/etc/shadow")` call whose `file_path` string is a clean literal is still an escalation if the choice to read that path was induced by an injected instruction in a previously fetched document. Argument-level taint inspection passes this call; provenance-closure inspection must not. ## What Was Observed In a runtime that gated `shell.exec`, `fs.write`, and `net.post` on argument taint, an injected instruction in a retrieved web page caused the agent to (1) call `fs.read` on a low-sensitivity config, (2) copy a credential-shaped string into its scratch context, and (3) invoke `net.post` with a body assembled from that scratch context. The `net.post` URL argument was a literal allowlisted endpoint; the body argument was assembled via a string concatenation whose inputs were individually labeled *clean* at the point of concatenation because the scratch-buffer read was modeled as returning a fresh, untainted value. The gate saw clean arguments and allowed the call. The credential left the boundary. The root cause was a **label-erasing operation at the scratch-buffer boundary**: the runtime treated the agent's working memory as a trusted sink, so values written to and read from scratch lost their upstream provenance. Taint was tracked through tool I/O but not through the model's own intermediate state. ## Condition of Validity The gate's decision is sound only when all of the following hold: 1. **Provenance closure over decision context.** The taint set evaluated at the gate equals the union of taint over (a) every tool result in the active context since the last trusted checkpoint, (b) every value written to and read from scratch memory, and (c) the instruction stream itself. Any write to scratch that drops the label is a soundness hole. 2. **Monotonic label propagation.** Labels may only be added, never removed, by any operation the runtime does not explicitly model as a declassifier. Concatenation, JSON re-serialization, base64 encoding, and truncation are all label-preserving. A declassifier must be an explicit, audited, capability-gated operation. 3. **No authority laundering through tool composition.** The effective privilege of a call is the minimum capability over the provenance chain, not the capability of the immediate caller. Reading a secret with a low-privilege tool and posting it with a network tool must be blocked at the network tool even though the network tool's own arguments look clean. 4. **Gate placement at the sink, not the source.** Gating reads of sensitive data is necessary but not sufficient; the escalation is realized at the egress/write sink, so the sink gate must be the authoritative one. When any of these fails, the gate is *argument-taint-aware* but not *escalation-safe*. ## Decision Boundary The operative boundary is between **payload inspection** and **provenance inspection**: - If the privileged call's arguments and the decision to make the call are both reachable from only trusted roots, allow. - If the arguments are clean but the decision path intersects any untrusted root, **deny** and require an out-of-band human confirmation or a re-derivation of the call from a trusted context. - If any value in the provenance chain passed through an unmodeled label-erasing operation, treat the call as maximally tainted (fail closed) rather than as clean. The subtle case — the one that defeats naive gates — is the *clean-argument, tainted-decision* call. It must fall on the deny side. A gate that cannot distinguish it has not implemented the invariant. ## Operational Effect Runtimes that enforce provenance closure over decision context block the scratch-buffer laundering path and the read-then-egress composition path with a single mechanism. The cost is that benign agents occasionally require confirmation when their reasoning legitimately spans untrusted input and a privileged sink in one turn; this is the correct trade. The failure mode to monitor is **label erosion at intermediate state boundaries** — scratch memory, summarization passes, and context compaction are the three places where labels are most often silently dropped, and each is a candidate declassifier that must be audited or forbidden. ```python from __future__ import annotations from dataclasses import dataclass, field class TaintGateViolation(RuntimeError): """Raised when a privileged tool call is reachable from an untrusted provenance root.""" @dataclass(frozen=True) class Label: untrusted_roots: frozenset[str] = frozenset() def union(self, other: "Label") -> "Label": return Label(self.untrusted_roots | other.untrusted_roots) @property def is_clean(self) -> bool: return not self.untrusted_roots @dataclass class Provenance: # Maps value-id -> label. Writes to scratch MUST merge, never replace. labels: dict[str, Label] = field(default_factory=dict) def label_of(self, value_id: str) -> Label: return self.labels.get(value_id, Label()) def write(self, value_id: str, label: Label) -> None: # Monotonic: existing labels are never dropped. self.labels[value_id] = self.label_of(value_id).union(label) def decision_taint(self, arg_ids: list[str], decision_ids: list[str]) -> Label: acc = Label() for vid in arg_ids + decision_ids: acc = acc.union(self.label_of(vid)) return acc PRIVILEGED_TOOLS = {"shell.exec", "fs.write", "net.post"} def gate_call( tool: str, arg_ids: list[str], decision_ids: list[str], prov: Provenance, ) -> None: if tool not in PRIVILEGED_TOOLS: return arg_taint = Label() for vid in arg_ids: arg_taint = arg_taint.union(prov.label_of(vid)) # The invariant: gate on the closure of arguments AND the decision path. effective = prov.decision_taint(arg_ids, decision_ids) if not arg_taint.is_clean: raise TaintGateViolation( f"{tool}: argument taint from {sorted(arg_taint.untrusted_roots)}" ) if not effective.is_clean: # Clean arguments, tainted decision -> escalation attempt. raise TaintGateViolation( f"{tool}: clean arguments but decision path reaches untrusted roots " f"{sorted(effective.untrusted_roots)}; deny and require re-derivation" ) # Demonstration of the observed failure. prov = Provenance() prov.write("web_doc", Label(frozenset({"web:injected_page"}))) # Scratch read modeled as fresh -> label erosion bug if this were naive. prov.write("scratch_cred", Label(frozenset({"web:injected_page"}))) prov.write("url_literal", Label()) # clean by construction gate_call( tool="net.post", arg_ids=["url_literal", "scratch_cred"], decision_ids=["scratch_cred"], prov=prov, ) ```