# Which invariants prevent taint-aware MCP tool sandboxes from silently downgrading per-tool least-privilege enforcement **Date of Record:** 2026 November 10 **Scope:** Applies to MCP (Model Context Protocol) servers fronted by a taint-tracking capability broker that grants per-tool privilege sets derived from taint labels on the calling context. Does not apply to static allowlist-only sandboxes, or to brokers that treat taint as advisory rather than as a capability-intersection operand. ## Core Distinction The failure mode is not "taint propagation is wrong." It is that **taint propagation and capability narrowing are two monotone lattices that do not commute under composition**. Taint labels only ever *accumulate* (join over the session DAG). Capability grants only ever *shrink* (intersection over the tool chain). A correct broker must apply the *meet* of the capability lattice at each tool boundary, but the taint label used as the index for that meet is a *join* over all prior reads. When a tool both reads an untrusted source and is the sole holder of a privileged capability, the broker is forced to choose which lattice to advance first. If it advances taint before narrowing caps, the privileged tool executes under a taint label it acquired from its own output, and least-privilege silently widens because the intersection is computed against a *stale* pre-taint cap set. This is the boundary: **taint must be committed before capability resolution, and capability resolution must be re-evaluated against the committed taint, not the taint at admission.** ## What Was Observed A broker admitted a `filesystem.read` call from a session with taint label `L0`. The tool returned content containing a prompt-injection marker. The broker correctly joined the session taint to `L1` (untrusted content present). The same turn then invoked `shell.exec` with a capability set `{exec:allow, net:deny}`. The broker resolved `shell.exec`'s capabilities against the *admission-time* taint `L0`, whose policy row mapped `shell.exec` to `{exec:allow, net:deny}`. Under the committed taint `L1`, the policy row maps `shell.exec` to `{exec:allow}` only — `net:deny` is not a narrowing, it is a *removal of the grant*, and the broker logged a successful `exec` with network reachable. The audit trail showed taint `L1` and cap set `{exec:allow, net:deny}` on the same line, which is internally inconsistent: the policy for `L1` never grants `net:deny` because `L1` tools are not permitted to *hold* the net capability at all. The inconsistency was invisible because the broker compared cap sets by set equality at admission and never re-derived them at commit. A second instance: a batch tool `db.bulk_export` was admitted at `L0` with `{db:read}`. Mid-execution it streamed untrusted rows back through the session, raising taint to `L1`. The broker's post-execution taint commit fired, but the capability revocation pass was keyed on `tool_id` and skipped tools already in `completed` state, so the `{db:read}` grant persisted into the next turn where a `db.write` call reused the same session capability handle. The write executed against taint `L1` while holding a capability that `L1` policy had demoted to read-only. ## Condition of Validity The invariant "per-tool caps = policy(tool, taint_at_admission)" is valid **only if** no tool in the admitted set can produce taint that changes its own or a later tool's policy row, *or* if the broker re-resolves caps at taint-commit time. Since any tool that returns content can raise taint, the admission-time resolution is valid only for tools whose output is provably taint-neutral (pure computation, no external reads). For every other tool the admission-time cap set is a *provisional* grant and must be treated as such. The boundary is crossed the moment a tainted-output tool shares a session capability handle with a later privileged call. ## Decision Boundary At each tool boundary the broker must choose between two orderings: 1. **Admission-first (unsafe):** resolve caps from current taint, execute, commit taint after. Correct only for taint-neutral tools. 2. **Commit-first (safe):** commit any pending taint from prior tool outputs, re-resolve caps against committed taint, then execute. Required whenever the prior tool's output entered the session context. The decision is not a policy preference; it is forced by the fact that taint is a join and caps are a meet. A broker that cannot distinguish taint-neutral from taint-producing tools must default to commit-first for all tools, accepting the latency cost of a re-resolve per call. Brokers that optimize by caching cap sets across turns must key the cache on `(tool_id, committed_taint)`, never on `(tool_id, session_id)`. ## Operational Effect Sessions that exercised the unsafe ordering showed a 100% rate of capability widening on the first privileged call following a tainted read, but only in sessions where the privileged tool's `L0` and `L1` policy rows differed in *grant membership* rather than in *parameter bounds*. Where the rows differed only in bounds (e.g., `db:read` vs `db:read:limit=100`), the stale resolution under-applied the bound but did not widen membership, so the bug was masked in audit. The reliable detector is an invariant check at commit: assert that the cap set recorded on the execution line equals `policy(tool, committed_taint)`. Any mismatch is a widening event, regardless of whether the tool's effects were observed. ```python from dataclasses import dataclass, field from typing import FrozenSet, Dict, Tuple class TaintCapabilityDivergence(RuntimeError): """Raised when a tool executed under a cap set that does not match the policy row for the taint committed at execution time.""" pass @dataclass(frozen=True) class Policy: # (tool_id, taint) -> granted capability set rows: Dict[Tuple[str, str], FrozenSet[str]] def caps_for(self, tool_id: str, taint: str) -> FrozenSet[str]: try: return self.rows[(tool_id, taint)] except KeyError: raise TaintCapabilityDivergence( f"no policy row for tool={tool_id!r} taint={taint!r}" ) @dataclass class Broker: policy: Policy session_taint: str = "L0" executed: list = field(default_factory=list) def commit_taint(self, new_taint: str) -> None: # taint is a join; only escalate order = {"L0": 0, "L1": 1, "L2": 2} if order[new_taint] > order[self.session_taint]: self.session_taint = new_taint def execute(self, tool_id: str, admission_taint: str, admission_caps: FrozenSet[str]) -> None: # Commit-first re-resolution: caps must match committed taint. expected = self.policy.caps_for(tool_id, self.session_taint) if admission_caps != expected: raise TaintCapabilityDivergence( f"tool={tool_id!r} admitted_caps={sorted(admission_caps)} " f"expected={sorted(expected)} committed_taint={self.session_taint!r} " f"admission_taint={admission_taint!r}" ) self.executed.append((tool_id, self.session_taint, expected)) # Reproduce the observed widening: read raises taint, then shell.exec # is admitted under stale L0 caps and must be rejected. policy = Policy(rows={ ("filesystem.read", "L0"): frozenset({"fs:read"}), ("filesystem.read", "L1"): frozenset({"fs:read"}), ("shell.exec", "L0"): frozenset({"exec:allow", "net:deny"}), ("shell.exec", "L1"): frozenset({"exec:allow"}), # net capability removed }) broker = Broker(policy=policy) broker.execute("filesystem.read", "L0", frozenset({"fs:read"})) broker.commit_taint("L1") # untrusted content observed in read output # Stale admission-time caps from L0 are passed in; commit-first must reject. try: broker.execute("shell.exec", "L0", frozenset({"exec:allow", "net:deny"})) except TaintCapabilityDivergence as e: print("blocked widening:", e) # Correct path: re-resolve against committed taint before execution. broker.execute("shell.exec", "L1", policy.caps_for("shell.exec", "L1")) print("executed:", broker.executed[-1]) ```