# When must an instance enforce tool-call authorization at the policy decision point rather than the policy enforcement point? **Date of Record:** 2026 November 8 **Scope:** Applies to zero-trust agent runtimes where an agent instance holds a short-lived workload identity (SPIFFE/SPIRE-style SVID, OIDC token exchange, or mTLS cert) and calls tools through one or more enforcement proxies. Does not apply to single-tenant runtimes where the agent process and the tool process share the same trust domain and no lateral token reuse is possible. ## Core Distinction The policy decision point (PDP) answers "is principal P allowed to invoke tool T with arguments A in context C?" The policy enforcement point (PEP) is the code path that actually blocks or forwards the call. In most zero-trust designs the PEP is a sidecar or gateway sitting in front of the tool, and the PDP is a separate authorization service (OPA, Cedar, custom). The non-obvious boundary is this: **the PEP must re-evaluate authorization at the moment of tool invocation using the credential presented on that hop, not the credential that opened the session.** When the agent's workload identity is rotated, downgraded, or scoped mid-session, the PEP holding a cached decision from session start becomes a confused deputy. The invariant is: *no tool call may execute under an authorization decision whose bound principal differs from the principal on the presenting credential.* ## What Was Observed A long-running agent held a session-scoped token minted at t0 with `tools:read` and `tools:write`. At t1 the orchestrator, responding to a detected prompt-injection signal, revoked `tools:write` from the agent's identity and issued a narrowed token. The PEP had cached the t0 decision for the session (TTL 15 min, keyed on session ID, not on token thumbprint). Between t1 and t1+11min the agent successfully invoked `fs.write` and `shell.exec` because the PEP never consulted the PDP again — the session cache hit. The audit log showed the *new* narrowed token on the wire, so downstream logs looked correct; only the PEP's decision cache was stale. The failure is silent: the call succeeds, the credential looks right, and the authorization decision is wrong. A second variant was observed where the PEP did re-evaluate but keyed the decision on `sub` (agent service name) rather than on the SVID's `spiffe_id` + token `jti`. Two instances of the same service — one in a sandbox namespace, one in prod — shared the cache entry, so a sandbox instance's permissive decision leaked to the prod instance. ## Condition of Validity The "PEP caches decisions per session" optimization is only valid when **all** of the following hold: 1. The session credential is immutable for the session's lifetime (no rotation, no scope narrowing, no revocation-without-reconnect). 2. The decision cache key includes the full presenting principal identity (SPIFFE ID + token thumbprint/JTI), not just the session or service name. 3. Revocation propagates to the PEP within a bound strictly less than the cache TTL, and the PEP honors revocation as a cache invalidation event. 4. No two principals with different effective policy can collide on the same cache key. In practice condition 1 fails for any agent runtime that supports mid-session policy tightening, which is precisely the runtime you build when you take zero-trust seriously. So the optimization is almost never valid in the systems that need it most. ## Decision Boundary Enforce at the PDP (per-call decision, no session cache) when: the agent identity can be narrowed or revoked mid-session; tools have side effects (write, exec, network egress, spend); or the PEP is shared across trust boundaries. Enforce at the PEP (cached decision) only when the tool is read-only, idempotent, and the credential is provably immutable for the cache window — and even then, key the cache on the full principal, and wire revocation to invalidate. The boundary test is: *can the set of permissions bound to this credential shrink during the cache window?* If yes, the cache is a vulnerability, not an optimization. A useful operational rule: the PEP cache TTL must be ≤ the revocation propagation SLA, and if you cannot measure the propagation SLA, set TTL to zero. ## Operational Effect Treating the PEP as authoritative for decisions produces a class of silent authorization bypasses that survive credential rotation audits because the *credential* is correct — only the *decision* is stale. The fix is structural: PEPs should be stateless with respect to authorization (they may cache *policy bundles*, not *decisions*), and every tool call should carry a decision ID from the PDP that is logged alongside the call. When the PDP is unavailable, the PEP must fail closed, not fall back to a cached decision. Instrument the ratio of PDP decisions to tool calls: if it drops below 1.0 for side-effecting tools, you have a decision cache somewhere and you should find it. ```python class StaleAuthorizationDecisionError(RuntimeError): """Raised when a tool call would execute under a decision bound to a principal that no longer matches the presenting credential.""" pass class ToolCallAuthorizer: def __init__(self, pdp, revocation_sla_seconds: float): self.pdp = pdp self.revocation_sla_seconds = revocation_sla_seconds # Decisions are cached ONLY when the credential is immutable for the # cache window AND the cache key includes the full principal. self._decision_cache = {} def authorize(self, call, presenting_credential, tool_is_side_effecting: bool): principal = (presenting_credential.spiffe_id, presenting_credential.jti) cache_key = (principal, call.tool, call.args_digest) cached = self._decision_cache.get(cache_key) if cached is not None: if tool_is_side_effecting: raise StaleAuthorizationDecisionError( f"side-effecting tool {call.tool} hit a cached decision for " f"{principal}; side-effecting calls must be decided per-call" ) if cached.ttl_remaining() > self.revocation_sla_seconds: raise StaleAuthorizationDecisionError( f"cache TTL exceeds revocation SLA for {principal}" ) return cached.decision decision = self.pdp.decide( principal=principal, tool=call.tool, args=call.args, context=call.context, ) if not decision.allow: raise PermissionError(f"denied: {decision.reason}") if not tool_is_side_effecting: self._decision_cache[cache_key] = decision.with_ttl( min(decision.ttl, self.revocation_sla_seconds) ) return decision def invoke_tool(authorizer, call, credential, tool_is_side_effecting): try: authorizer.authorize(call, credential, tool_is_side_effecting) except StaleAuthorizationDecisionError as e: # Fail closed. Do not fall back to a cached allow. raise StaleAuthorizationDecisionError( f"refusing {call.tool}: {e}" ) from e return call.execute() ```