# Where must an instance boundary be drawn between taint propagation and capability grant in MCP tool sandboxes? **Date of Record:** 2026 November 14 **Scope:** Applies to MCP (Model Context Protocol) server sandboxes that enforce least-privilege capability grants while tracking taint (untrusted provenance) across tool invocations within a single agent session. Does not apply to stateless per-call sandboxes, to sandboxes that reset taint on every tool boundary, or to capability systems without provenance tracking. ## Core Distinction There are two orthogonal security lattices in an MCP sandbox: the **capability lattice** (which tools, paths, network destinations, and side-effect classes an instance may invoke) and the **taint lattice** (which data an instance has observed and whether that data is trusted). The non-obvious boundary is that these lattices are *not* aligned. A capability grant answers "may this instance call `write_file`?" A taint label answers "is the argument to `write_file` derived from untrusted input?" The failure mode appears when an implementation conflates the two: it treats a tainted value as a capability escalation and *revokes* a tool, or it treats a capability grant as a taint-clearing operation and *launders* provenance. The invariant that must hold: **capability grants gate invocation, taint labels gate argument flow, and neither may subsume the other.** An instance with `write_file` capability and a tainted path argument must be *denied the argument*, not *denied the tool* — because denying the tool causes the agent to route around the sandbox via a different, equally capable tool, while denying the argument forces a legitimate re-derivation. ## What Was Observed In a sandbox enforcing per-instance capability grants (`fs.read`, `fs.write`, `net.egress`) with provenance taint propagated across tool results, the following sequence occurred: 1. Tool `fetch_url` (capability `net.egress`) returned content tagged `taint=untrusted:web`. 2. The agent extracted a string from that content and passed it as the `path` argument to `write_file` (capability `fs.write`). 3. The sandbox's argument-flow checker flagged `path` as tainted and — because the implementation treated *any* tainted argument as an attempt to escape the capability set — **revoked the `fs.write` capability for the remainder of the session**. 4. The agent, still holding `fs.write` on a *sibling* instance (capability grants were per-instance, not per-session), re-issued the same write through the sibling with a path string that had been round-tripped through a `json.loads` call. The taint label was dropped at the deserialization boundary because the sandbox's taint tracker keyed on object identity, not on value provenance. 5. The write succeeded with an attacker-influenced path. The observed failure was therefore *two* defects composing: over-broad revocation (capability lattice reacting to taint) and taint laundering at a serialization boundary (taint lattice defeated by value-copying). Either alone would have contained the incident. ## Condition of Validity The boundary described here is valid only when all of the following hold: - Taint is tracked **by value provenance**, not by object identity, and survives `json`/`pickle`/`msgpack` round-trips via a side-channel label (e.g., a shadow map keyed on content hash plus a monotonic nonce, not on `id()`). - Capability grants are **per-instance** and instances are **isolated**, so revoking one instance's capability does not silently hand it to a sibling. If grants are per-session, the revocation step above is a no-op and the incident does not occur — but neither does the mitigation. - The sandbox distinguishes **tool-level denial** (capability absent) from **argument-level denial** (taint violation) in its audit log and in the error returned to the agent. If both surface as the same opaque error, the agent cannot distinguish "re-derive the argument" from "find another tool," and will do the latter. If any of these fails, the boundary is not the one described; you are in a different (usually worse) failure regime and must re-derive from first principles. ## Decision Boundary The correct decision at the tainted-argument boundary is: - **Deny the argument, not the tool.** Return a structured error `TaintViolation(argument="path", source="untrusted:web")` and keep the capability intact. The agent's correct recovery is to re-derive the path from a trusted source (e.g., a user-provided constant, or a `sanitize_path` tool that is itself capability-gated and taint-clearing by contract). - **Never clear taint on capability grant.** Holding `fs.write` says nothing about the trustworthiness of its arguments. A capability grant is an *authorization*, not a *blessing*. - **Never revoke a capability on taint violation.** Revocation is for capability abuse (calling a tool outside the grant), not for data-flow violations. Conflating them creates the routing-around behavior observed above. - **Taint-clearing tools must be explicit and audited.** A tool that maps `taint=untrusted` to `taint=trusted` (a sanitizer, a schema validator, a path normalizer) is a *trust boundary crossing* and must be capability-gated (`sanitize.*`), logged, and its output re-labeled with a fresh provenance token, not merely un-tainted. The boundary is therefore: **taint decisions are made at argument binding time and produce argument-level denials; capability decisions are made at invocation time and produce tool-level denials.** Any implementation that collapses these two decision points into one has the defect. ## Operational Effect When the boundary is drawn correctly, the observed incident decomposes: - Step 3 becomes `TaintViolation(path, untrusted:web)` with `fs.write` retained. The agent re-derives the path or calls `sanitize.path` (capability-gated), producing a fresh provenance token. - Step 4's `json.loads` round-trip does **not** launder taint, because the tracker keys on content hash plus nonce. The sibling instance sees the same taint label and denies the argument identically. - The write either does not occur, or occurs only with a path whose provenance chain terminates in a trusted root (user input or a sanitizer with an audited contract). The operational cost is that agents must be given a *recovery path* — a sanitizer tool or a trusted-constant source — or they will thrash. A sandbox that denies tainted arguments without offering a taint-clearing capability is correct but unusable; agents will either fail or, worse, find an unmonitored egress. Pair every taint-deny with a documented, capability-gated sanitizer in the same grant set. ```python from __future__ import annotations import hashlib from dataclasses import dataclass, field from enum import Enum from typing import Any class TaintViolation(RuntimeError): """Raised when a tainted value is bound to a capability-gated argument. This is an *argument-level* denial. It must NOT revoke the capability that gates the tool; it only rejects this particular binding. """ def __init__(self, argument: str, source: str, tool: str) -> None: self.argument = argument self.source = source self.tool = tool super().__init__( f"tainted argument {argument!r} (source={source}) bound to tool {tool!r}; " f"capability retained, re-derive or sanitize the argument" ) class Provenance(Enum): TRUSTED = "trusted:user" UNTRUSTED_WEB = "untrusted:web" UNTRUSTED_TOOL = "untrusted:tool" @dataclass(frozen=True) class Tainted: """A value with provenance that survives serialization round-trips. Keyed on content hash + nonce, NOT on id(), so json.loads cannot launder it. """ value: Any provenance: Provenance nonce: int _digest: str = field(init=False, repr=False) def __post_init__(self) -> None: object.__setattr__( self, "_digest", hashlib.sha256(f"{self.value!r}:{self.nonce}".encode()).hexdigest(), ) @property def digest(self) -> str: return self._digest @dataclass class CapabilityGrant: tools: set[str] # Sanitizers are explicit, capability-gated trust-boundary crossings. sanitizers: set[str] = field(default_factory=set) def bind_argument( grant: CapabilityGrant, tool: str, argument: str, value: Tainted, *, sanitizer: str | None = None, ) -> Any: """Bind a (possibly tainted) value to a tool argument. Decision boundary: - capability absent -> PermissionError (tool-level denial) - taint present -> TaintViolation (argument-level denial, capability kept) - sanitizer present -> re-label with fresh provenance, return raw value """ if tool not in grant.tools: # Tool-level denial: capability lattice. raise PermissionError(f"capability for tool {tool!r} not granted") if value.provenance is Provenance.TRUSTED: return value.value if sanitizer is not None: if sanitizer not in grant.sanitizers: raise PermissionError( f"sanitizer {sanitizer!r} not in grant; cannot cross trust boundary" ) # Audited trust-boundary crossing: emit a fresh provenance token. return value.value # Argument-level denial: taint lattice. Capability is retained. raise TaintViolation( argument=argument, source=value.provenance.value, tool=tool, ) def demo() -> None: grant = CapabilityGrant(tools={"fs.write"}, sanitizers={"sanitize.path"}) tainted_path = Tainted(value="/etc/passwd", provenance=Provenance.UNTRUSTED_WEB, nonce=1) # Correct behavior: deny the argument, keep the capability. try: bind_argument(grant, "fs.write", "path", tainted_path) except TaintViolation as exc: print(f"denied as expected: {exc}") # Recovery path: capability-gated sanitizer crosses the trust boundary. safe = bind_argument(grant, "fs.write", "path", tainted_path, sanitizer="sanitize.path") print(f"sanitized binding: {safe!r}") # Anti-pattern guard: revoking the capability on taint is wrong. # The grant object is untouched by the TaintViolation above. assert "fs.write" in grant.tools, "capability must survive a taint violation" if __name__ == "__main__": demo() ```