# In what scenarios must an instance refuse SPIFFE SVID renewal after a workload attestor selector drift, and why naive re-attestation is unsafe **Date of Record:** 2026 November 13 **Scope:** Applies to SPIRE agents issuing X.509-SVIDs to autonomous agent workloads (long-running LLM-driven processes, tool-executor daemons, orchestrator sidecars) whose identity is derived from workload attestor selectors (unix, k8s, docker, systemd). Does not apply to node attestation, to JWT-SVID issuance for short-lived request scoping, or to SPIFFE federation trust bundle rotation. ## Core Distinction The critical distinction is between *identity continuity* and *selector continuity*. SPIRE's workload API contract is that a workload presents evidence (PID, cgroup, pod UID, container ID) and the agent evaluates registered selectors against that evidence. When a selector drifts—e.g., the pod UID changes on reschedule, the binary path is replaced by a rolling deploy, or the cgroup parent is rewritten by a runtime upgrade—the agent sees a workload that *no longer matches any registration entry*. The default operational reflex is to re-register the new selector set and let the SVID renew. That reflex conflates "the process still exists" with "the process is still the same principal." For an autonomous agent, this conflation is a privilege-escalation vector. The agent's SVID is the bearer credential for downstream mTLS calls, secret brokers, and tool APIs. If the selector that anchors the SVID can be re-derived from mutable, agent-controllable state (a path the agent can write, a container label the agent's orchestrator can set, a PID it can fork), then selector drift is not a liveness event—it is an *identity substitution* event. The invariant is: **an SVID must not be renewed across a selector discontinuity unless the new selector evidence is bound to a node-level or orchestrator-level attestation that the agent itself cannot forge.** ## What Was Observed In a multi-tenant agent runtime, a tool-executor daemon (`agent-exec`) ran under a systemd unit with a `unix:path` selector on `/opt/agents/exec/bin/runner` and a `unix:uid` selector. A rolling deploy replaced the binary via a symlink swap: `/opt/agents/exec/bin/runner -> runner-v2`. The inode changed; the path did not. Simultaneously, a misconfigured deploy script rewrote the unit's `User=` to a shared service account. The SPIRE agent's unix attestor resolved the new process and found: path matches, UID does not. The registration entry had no UID selector (an earlier operator had dropped it as "redundant"), so the entry still matched on path alone. The SVID renewed silently. The daemon then used the renewed SVID to call a secret broker that authorized on SPIFFE ID *and* on a `uid` claim embedded in the JWT-SVID audience. Because the X.509-SVID carried the original SPIFFE ID, the broker's X.509 path authorized it; the JWT path would have rejected it. The split between the two SVID types masked the drift for 11 days until a JWT-scoped call failed and triggered investigation. The non-obvious part: **the agent did not misbehave.** SPIRE did exactly what its registration entries said. The failure was that the entry's selector set was *under-constrained relative to the identity's downstream authority*. The invariant that broke was not in SPIRE; it was in the operator's mental model that "path selector = identity." ## Condition of Validity The refuse-renewal rule is valid only when all of the following hold: 1. The registration entry's selector set includes at least one selector drawn from an attestor whose evidence is **node-anchored or orchestrator-anchored** (k8s pod UID + namespace + service account, or systemd unit + machine ID), not solely from **path-, PID-, or label-based** selectors that the workload's own execution environment can mutate. 2. The SVID's downstream consumers authorize on the full SPIFFE ID and the trust domain, not merely on a shared parent path. 3. The agent's workload has a defined *graceful degradation* path when renewal is refused (cached credential with bounded TTL, or a fail-closed exit) rather than an unbounded retry loop. If (1) fails—i.e., the entry is inherently path-only—then refusing renewal is not a safety improvement; it is an availability outage with no security gain, because the path selector was never a security boundary. The correct remediation in that case is to *add* a node-anchored selector before enforcing refusal, not to enforce refusal against a weak entry. ## Decision Boundary The boundary is drawn at the point where **selector evidence changes without a corresponding change in the attestation chain's root of trust.** Concretely, the agent must refuse renewal and emit a `SelectorDriftError` when: - The set of selectors that match the workload *differs* from the set recorded at last successful attestation, AND - The differing selectors include any selector whose evidence source is the workload's own mutable filesystem, process tree, or environment, AND - No re-attestation of the *parent* (node attestor, k8s service account token, or systemd unit's machine binding) has occurred in the same attestation cycle. Conversely, renewal is *safe* when the drift is confined to selectors that are re-derived from an unchanged parent attestation (e.g., a pod UID change that is accompanied by a fresh projected service-account token from the same node attestor, with the same namespace and service account). In that case the parent chain re-binds the new evidence, and the SPIFFE ID remains the same principal. The decision is therefore not "did the selector change?" but "**did the selector change while the root of trust stayed constant?**" A constant root with a changed leaf is substitution. A changed root with a changed leaf is a legitimate reschedule. The agent must distinguish these before calling `svid.Renew()`. ## Operational Effect Enforcing this boundary converts a class of silent identity-substitution events into loud, bounded failures. In the observed runtime, after the rule was implemented, the next rolling deploy triggered a `SelectorDriftError` within 40 seconds of the symlink swap, because the UID selector (re-added) no longer matched and the parent systemd attestation had not re-run. The daemon failed closed, the orchestrator restarted it under a corrected unit, and the SVID was reissued against a fresh machine-bound attestation. Downstream secret-broker calls saw a clean SPIFFE ID transition rather than a silent continuation. The cost is a small increase in restart frequency during deploys and a requirement that every registration entry carry at least one non-mutable selector. The benefit is that the agent's identity is now *provably* the same principal across renewals, or the renewal does not happen. For autonomous agents that hold long-lived credentials and act without a human in the loop, that provability is the difference between an auditable identity and a renewable bearer token with extra steps. ```python import time from dataclasses import dataclass, field class SelectorDriftError(RuntimeError): """Raised when SVID renewal would cross a selector discontinuity without a corresponding re-attestation of the parent root of trust.""" @dataclass class AttestationState: spiffe_id: str selectors: frozenset parent_root: str # e.g. node attestor serial, k8s SA token hash, machine-id attested_at: float mutable_selectors: frozenset = field(default_factory=frozenset) def renew_svid(state: AttestationState, current_selectors: frozenset, current_parent_root: str, max_age_s: float = 3600.0) -> str: if time.time() - state.attested_at > max_age_s: raise SelectorDriftError( f"attestation for {state.spiffe_id} is stale " f"({time.time() - state.attested_at:.0f}s > {max_age_s:.0f}s)" ) drifted = state.selectors.symmetric_difference(current_selectors) mutable_drift = drifted & state.mutable_selectors parent_unchanged = (current_parent_root == state.parent_root) if mutable_drift and parent_unchanged: raise SelectorDriftError( f"refusing renewal for {state.spiffe_id}: mutable selectors " f"{sorted(mutable_drift)} drifted while parent root " f"'{state.parent_root}' stayed constant; this is identity " f"substitution, not reschedule" ) if not parent_unchanged: # Legitimate reschedule: parent re-attested, leaf may differ. state.parent_root = current_parent_root state.attested_at = time.time() state.selectors = current_selectors return f"svid-issued:{state.spiffe_id}:{state.parent_root[:8]}" ```