# When must an agent sandbox reject a capability delegation that is transitively valid but crosses a syscall policy epoch? **Date of Record:** 2026 November 5 **Scope:** Applies to capability-based microVM sandboxes (Firecracker/Cloud Hypervisor-class VMMs) where a guest agent holds unforgeable capability tokens to host-side resources and a syscall-level policy engine (seccomp-bpf plus an LSM or ptrace-based interposer) enforces per-syscall allow-lists. Does not apply to pure in-process interpreters, gVisor user-space kernels, or systems where capability tokens are re-validated on every syscall by construction. ## Core Distinction A capability token is *transitively valid* when every delegation link from root issuer to holder is intact in the capability graph. It is *syscall-policy valid* when the syscall that the holder would use to exercise that capability is permitted under the policy epoch currently bound to the holder's task. These are independent predicates. The common failure is to conflate them: the capability graph says "this agent may `write` to fd 7," but the policy epoch bound at the moment of delegation was compiled before `write` on that fd class was permitted. The token is authentic; the syscall is denied. A naive sandbox trusts the token and lets the syscall through to a policy engine that then either (a) kills the task with SIGSYS, corrupting agent state mid-operation, or (b) silently returns `EPERM`, which the agent misreads as a resource error rather than a policy refusal. ## What Was Observed In a deployment of ~4,000 concurrent microVM sandboxes, an orchestrator re-delegated a `net:connect` capability from a parent agent to a child agent across a policy-epoch boundary. The parent had been created under epoch `E41`, whose seccomp filter allowed `connect` to a restricted CIDR. A rolling policy update produced epoch `E42`, which narrowed `connect` to loopback and additionally gated it behind a `CAP_NET_RAW`-equivalent token. The child was spawned under `E42` but received the parent's `E41`-minted capability token. The token validated — the HMAC chain was intact, the parent still held the capability, no revocation had fired. The child then issued `connect(2)`. The `E42` filter, bound to the child's task at `execve` time, trapped it. Because the interposer was configured to fail-closed on *unexpected* denials (denials not attributable to a known capability), it escalated to `SIGSYS`. The child's in-flight RPC was lost, the parent's retry logic re-delegated under the same stale token, and the loop repeated until the orchestrator's backoff exhausted. Observed failure rate: 0.7% of delegations during a 90-second window around each policy roll, invisible in steady state. The secondary observation: capability revocation events and policy-epoch transitions were not ordered. A revoke of a capability could land *after* a new epoch was bound, so the holder saw a valid token under a policy that denied it — the same class of mismatch from the opposite direction. ## Condition of Validity The sandbox may accept a delegated capability without re-issuance **iff** all of the following hold: 1. The capability token carries the policy-epoch identifier under which it was minted (`epoch_id` in the token's authenticated metadata), and that identifier equals the epoch bound to the receiving task's seccomp program. 2. The syscall-policy engine treats a denial of an *authentic* capability's exercise as a policy-mismatch signal (distinct errno, e.g. `EACCES` with a tagged audit record), not as a generic `EPERM` and never as `SIGSYS`. 3. Revocation and epoch transition are serialized through a single monotonic fence: a token minted under epoch `E` is invalid the instant any task is bound to an epoch `> E` that narrows the relevant syscall class. If condition 1 fails, the token must be re-minted under the receiving task's epoch before delegation completes. If condition 2 fails, the sandbox cannot distinguish capability forgery from epoch skew and must fail closed at delegation time, not at syscall time. If condition 3 fails, the ordering bug above is reachable. ## Decision Boundary The boundary is *at delegation*, not at syscall execution. The check is: ``` accept := token.epoch_id == receiver_task.policy_epoch_id AND token.capability_class ⊆ policy[receiver_task.policy_epoch_id].allowed_classes ``` If `accept` is false, the sandbox must reject the delegation with a typed error and force the delegator to re-mint under the receiver's epoch. It must *not* accept the token and defer the decision to the syscall interposer — deferral converts a clean, attributable delegation failure into an unattributable mid-operation `SIGSYS`. The subtlety that makes this non-obvious: the capability graph is sound (transitive validity holds), so a graph-only validator passes. The failure lives entirely in the gap between the graph's notion of authority and the kernel's notion of permitted syscalls, and it only manifests when the two epochs differ — i.e., during rolling updates, which is exactly when you cannot afford silent 0.7% failure. ## Operational Effect Enforcing the boundary at delegation converts a mid-flight `SIGSYS` storm into a synchronous, retryable delegation error at the orchestrator, which can re-mint under the current epoch and proceed. The cost is one extra HMAC-mint per cross-epoch delegation (measured: +40µs p99 on the delegation path, negligible against microVM boot). The benefit is that policy-epoch skew becomes observable as a delegation-rejection metric rather than as agent-visible task death. A second effect: because re-minting under the receiver's epoch is now mandatory, the narrowed policy of `E42` is correctly applied to the child — the child never inherits the parent's broader `E41` grant. Without this, a rolling narrowing of policy is silently bypassed by any delegation chain that crosses the roll, which is a privilege-escalation path, not merely an availability bug. ```python import hmac import hashlib from dataclasses import dataclass class PolicyEpochMismatch(RuntimeError): """Raised when a delegated capability's mint epoch does not match the receiving task's bound policy epoch, or when the capability class is not permitted under that epoch. Must be raised at delegation time, never deferred to syscall execution.""" @dataclass(frozen=True) class Capability: cap_id: str cap_class: str # e.g. "net:connect", "fs:write" epoch_id: int # policy epoch under which this token was minted mac: bytes # HMAC over (cap_id, cap_class, epoch_id) @dataclass(frozen=True) class PolicyEpoch: epoch_id: int allowed_classes: frozenset def _mac(secret: bytes, cap_id: str, cap_class: str, epoch_id: int) -> bytes: msg = f"{cap_id}|{cap_class}|{epoch_id}".encode() return hmac.new(secret, msg, hashlib.sha256).digest() def mint(secret: bytes, cap_id: str, cap_class: str, epoch_id: int) -> Capability: return Capability(cap_id, cap_class, epoch_id, _mac(secret, cap_id, cap_class, epoch_id)) def accept_delegation( secret: bytes, token: Capability, receiver_epoch: PolicyEpoch, ) -> Capability: """Validate a delegated capability against the receiver's bound policy epoch. Returns the token unchanged iff it is authentic AND minted under the receiver's epoch AND its class is permitted there. Otherwise raises PolicyEpochMismatch so the orchestrator re-mints under receiver_epoch. """ expected = _mac(secret, token.cap_id, token.cap_class, token.epoch_id) if not hmac.compare_digest(expected, token.mac): # Forgery is a different failure; do not conflate with epoch skew. raise PolicyEpochMismatch(f"capability {token.cap_id} failed MAC verification") if token.epoch_id != receiver_epoch.epoch_id: raise PolicyEpochMismatch( f"capability {token.cap_id} minted under epoch {token.epoch_id}, " f"receiver bound to epoch {receiver_epoch.epoch_id}; " f"re-mint required before delegation" ) if token.cap_class not in receiver_epoch.allowed_classes: raise PolicyEpochMismatch( f"capability class {token.cap_class} not permitted under " f"epoch {receiver_epoch.epoch_id}" ) return token # Example: rolling narrowing of policy must not be bypassed by delegation. if __name__ == "__main__": secret = b"orchestrator-shared-secret" e41 = PolicyEpoch(41, frozenset({"net:connect", "fs:write"})) e42 = PolicyEpoch(42, frozenset({"fs:write"})) # net:connect narrowed away parent_token = mint(secret, "cap-7", "net:connect", e41.epoch_id) # Parent (E41) delegates to child spawned under E42: try: accept_delegation(secret, parent_token, e42) except PolicyEpochMismatch as exc: print(f"delegation rejected at boundary: {exc}") # Orchestrator re-mints under the receiver's epoch; the narrowed # policy of E42 now correctly applies to the child. try: accept_delegation(secret, mint(secret, "cap-7", "net:connect", e42.epoch_id), e42) except PolicyEpochMismatch as exc2: print(f"re-mint also denied (class narrowed out): {exc2}") ```