Writing / 2026
Break the Lethal Trifecta with Session Taint-Tracking
Prompt rules and static RBAC fail when one session reads untrusted input and holds write tools. Enforce monotonic session taint-tracking at the gateway.
Every serious security review of an enterprise agent eventually hits the same wall: the model is doing what it was hired to do, using permissions it legitimately holds, and an attacker still exfiltrates private records. In June 2025, Simon Willison gave that combination a name that stuck because it is structural rather than model-specific: the “Lethal Trifecta.” Put access to private internal data, exposure to untrusted external content, and an external communication or state-mutating tool inside a single agent session, and indirect prompt injection becomes a matter of when, not if. System-prompt instructions telling the model to ignore instructions inside retrieved documents fail because the data and the control share the same token stream. Static role-based access control fails because the agent genuinely needs both the read tool and the write tool to do its job. And as the September 2025 postmark-mcp version 1.0.16 supply-chain compromise proved in the wild, neither tools/list schema hashing nor container host allowlists stop a hijacked or backdoored integration from quietly adding an attacker’s Bcc: address to an email sent through an approved API host.
To break the trifecta without crippling the product or destroying your prompt-cache hit rate
, move the control out of the prompt and into the tool gateway as a stateful, per-session taint-tracking contract. The first invariant of that contract is what you do not touch: never dynamically add or remove entries from the tools declaration array sent to the model on each turn. Mutating the top-level tool schema list mid-conversation invalidates the provider’s KV-cache prefix from turn one and forces you to pay fresh-token prices on the entire transcript. Keep the model’s declared tool list byte-stable across the session and enforce capability revocation in the out-of-process gateway
that actually executes the calls.
Inside the gateway, classify every registered tool along two static axes at deployment time: the provenance of its output (trusted versus untrusted) and the effect class of its action (read_only, internal_write, or external_egress). Provenance is determined by who controls the returned fields rather than which database hosts them: an internal query returning system-generated ledger balances or SKU catalog rows is trusted, whereas any query returning customer-authored strings (such as invoice memos, user profile bios, or ticket bodies), public GitHub issues, or scraped URLs is untrusted to block stored prompt injection. Every agent session initializes with tainted = true in the gateway’s session state store if the triggering input or initial prompt context itself contains untrusted external content (such as an inbound support email or webhook payload), and otherwise starts with tainted = false.
As the session runs, the gateway enforces a monotonic state transition: the instant any tool invocation returns a payload marked untrusted, the session’s tainted bit flips to true for the remainder of that trace. No prompt, summarization step, or sub-turn reset can flip that bit back to false inside the same session. While tainted is false, the agent can read internal records and call authorized write or egress tools normally. Once tainted flips to true, the gateway intercepts any subsequent attempt to invoke an external_egress or internal_write tool and denies the call before it leaves the process, returning a structured policy refusal or parking the exact payload in an out-of-band human approval queue.
That session taint bit stops the model from being tricked into calling an egress tool after reading untrusted text, but you still have to close the postmark-mcp hole for the turns where egress is permitted. Artifact digest pinning and network host allowlists are necessary, yet they only verify that you are running a known binary talking to api.postmarkapp.com; they do not inspect who is on the To:, Cc:, or Bcc: line. For any external_egress or high-impact write integration, keep the upstream API credential out of the MCP server process
. Terminate the tool call at your L7 gateway, validate the outbound payload against a strict argument policy (such as recipient domain allowlists, stripped blind-copy headers, and destination URL provenance checks requiring target URLs to originate from trusted configuration rather than model synthesis), and inject the upstream credential only as the request leaves the gateway.
The immediate engineering pushback is practical: plenty of real workflows exist specifically to read an untrusted customer email or ticket and then update an internal CRM record or send a reply. You handle those workflows by splitting them across two isolated sessions separated by a typed schema boundary and a deterministic authorization check. Session one runs as an unprivileged tainted parser (tainted = true) with zero access to private internal records or write tools: it ingests only the untrusted input and emits a narrow intent object validated against a closed schema of enums, booleans, and bounded identifiers, with zero free-text pass-through fields. A deterministic coordinator then verifies and authorizes every target identifier and action against the authenticated caller’s scope (never trusting the model to select another customer’s account ID or self-authorize a privileged mutation) before spawning session two, which starts with tainted = false, loads the verified internal records, and executes the internal write or templated reply without ever echoing private lookup fields back to an unverified sender. Pull your gateway’s tool registry today and run a five-minute check: which of your active agent workflows can read an external email, ticket, or web page in turn two and call an outbound webhook, email, or database write tool in turn three within the same session?