# How should an instance distinguish a sandbox escape from a legitimate skill escalation when syscall-level capability filters are the only enforcement boundary **Date of Record:** 2026 November 14 **Scope:** This record applies to agent runtimes that enforce skill isolation exclusively through seccomp/BPF-style syscall filters (with optional landlock/namespace confinement) and where skills are loaded dynamically at runtime from a registry. It does not apply to runtimes with a hypervisor or VM boundary, nor to runtimes where capability grants are pre-declared statically at process start and never mutated. ## Core Distinction A syscall-level capability filter answers exactly one question: *is this process permitted to invoke this syscall with these arguments?* It does not answer the question the runtime actually needs answered at escalation time: *did the skill acquire this capability through a sanctioned path, or did it reach the syscall by a path the filter was never designed to model?* The non-obvious failure is that these two questions collapse into the same observable — a denied or newly-permitted syscall — the moment a skill runtime supports dynamic capability escalation (e.g., a skill requests `openat` on a new path, or `socket` for an outbound connection, at execution time rather than at load time). A filter that is correct at load time becomes ambiguous at escalation time. The filter cannot tell a legitimate mid-execution grant from a filter-bypass primitive, because both present to the filter as "a syscall that was previously blocked is now allowed." The invariant that actually holds: **the syscall filter is a necessary but not sufficient boundary.** It constrains what a compromised skill *can* do, but it cannot, by itself, distinguish escalation from escape. That distinction must be carried out-of-band, by the runtime's grant ledger, not inferred from filter state. ## What Was Observed A skill executing under a filter that denied `openat` outside a read-only root, `socket`, and `execve` requested — through the runtime's sanctioned escalation API — permission to open a writable scratch directory. The runtime granted it by *widening the filter program* for that process (a common implementation: recompile the seccomp filter with an additional allowed path prefix and reinstall it via `seccomp()` with `SECCOMP_FILTER_FLAG_TSYNC` or by forking a re-filtered child). Two things were then true simultaneously: 1. The skill's subsequent `openat` on `/scratch/...` succeeded, as intended. 2. A separate, previously-compromised code path in the same skill — one that had been probing for writable file descriptors — observed that the filter had changed, and used the *newly widened* filter to reach a path under `/scratch/../etc/...` that the path-prefix check did not normalize. The filter was not bypassed in the kernel sense. It was *legitimately widened*, and the widening was the attack surface. The filter could not distinguish the sanctioned escalation from the exploit because both arrived as the same syscall under the same widened policy. The only signal that distinguished them was the grant ledger: the sanctioned escalation had a matching grant record with a skill-attested justification; the exploit's `openat` did not, but by the time it executed the filter had already been widened and had no memory of *why*. A second observation: re-filtering is not atomic with respect to sibling threads. Under `TSYNC`, a thread that was mid-syscall during the filter swap could complete the old-policy syscall after the new policy was installed, producing a window in which the *weaker* of the two policies governed. This window is small but deterministic under load and was reproducible. ## Condition of Validity The distinction "escalation vs. escape" is only well-defined when all three of the following hold: 1. **Every filter mutation is preceded by a grant-ledger write**, and the ledger entry is keyed to the skill's identity and the specific syscall+argument tuple being granted. If the filter can be widened without a ledger entry, the distinction is undefined. 2. **Path arguments are canonicalized before the filter sees them**, and the canonicalization is performed by the runtime, not the skill. If the skill controls the path string that the filter matches, prefix checks are not a boundary. 3. **Filter swaps are serialized against all threads of the process**, with a barrier that guarantees no syscall is in flight across the swap. Without this, the window described above is a live race, not a theoretical one. When any of these fails, the runtime must treat *all* syscall activity following a filter mutation as untrusted, because it can no longer attribute the activity to a grant. ## Decision Boundary The operational rule the runtime should encode: - A syscall permitted by the *current* filter is **not** evidence of authorization. Authorization is evidenced only by a ledger entry matching the syscall tuple, written before the filter was widened to permit it. - On any filter mutation, the runtime must **snapshot the ledger** and, for each subsequent syscall the filter permits that was not permitted under the prior filter, require a matching ledger entry within the same execution epoch. A permitted syscall with no matching entry is an **unattributed capability acquisition** — treat it as escape until proven otherwise. - If a filter swap cannot be proven atomic across threads, the runtime must **quarantine the process** (deny all newly-permitted syscalls until re-filtered from a clean state) rather than proceed. The boundary is therefore not "did the filter deny it?" but "can the runtime attribute the permission to a grant?" A filter that denies is safe; a filter that permits is only safe if the permit is attributable. ## Operational Effect Concretely, this changes three runtime behaviors: 1. **Escalation becomes a two-phase commit**: ledger-write, then filter-widen, then barrier, then resume. A crash between ledger-write and filter-widen leaves an orphan grant (harmless, revocable); a crash between filter-widen and barrier leaves an unattributed window (must be quarantined on recovery). 2. **Path canonicalization moves into the runtime**, ahead of the filter, and the filter matches only canonical forms. Skills never supply the string the filter compares. 3. **The runtime gains a post-hoc audit pass**: every syscall permitted after a mutation is replayed against the ledger snapshot; unattributed permits raise a hard error that halts the skill, not a warning. The following reference implementation encodes the attribution check and the atomicity requirement, raising a specific error when a permitted syscall cannot be attributed to a grant in the current epoch. ```python import os import threading from dataclasses import dataclass, field from typing import Optional class UnattributedCapabilityError(RuntimeError): """A syscall was permitted by the filter but has no matching grant in the current execution epoch. Treat as a sandbox escape until proven otherwise.""" pass class FilterSwapRaceError(RuntimeError): """A filter mutation could not be proven atomic across all threads.""" pass @dataclass(frozen=True) class Grant: skill_id: str syscall: str arg_tuple: tuple epoch: int @dataclass class GrantLedger: epoch: int = 0 _grants: dict = field(default_factory=dict) _lock: threading.Lock = field(default_factory=threading.Lock) def begin_mutation(self) -> int: with self._lock: self.epoch += 1 return self.epoch def record(self, grant: Grant) -> None: with self._lock: self._grants[(grant.skill_id, grant.syscall, grant.arg_tuple, grant.epoch)] = True def is_attributed(self, skill_id: str, syscall: str, arg_tuple: tuple, epoch: int) -> bool: with self._lock: return (skill_id, syscall, arg_tuple, epoch) in self._grants def canonicalize(path: str) -> str: # Runtime-owned normalization; the skill never supplies the compared form. return os.path.realpath(os.path.abspath(path)) def escalate_and_widen( ledger: GrantLedger, skill_id: str, syscall: str, raw_arg: str, apply_filter_widen, thread_barrier, ) -> None: """Two-phase commit: ledger-write, widen filter, barrier, resume.""" arg_tuple = (canonicalize(raw_arg),) epoch = ledger.begin_mutation() # Phase 1: ledger write (orphan on crash is safe and revocable). ledger.record(Grant(skill_id, syscall, arg_tuple, epoch)) # Phase 2: widen filter, then prove atomicity across threads. apply_filter_widen(syscall, arg_tuple) if not thread_barrier.wait(timeout=0.5): # Cannot prove no syscall was in flight across the swap. raise FilterSwapRaceError( f"filter swap for {skill_id}:{syscall} not proven atomic; quarantine required" ) def check_permitted_syscall( ledger: GrantLedger, skill_id: str, syscall: str, raw_arg: str, current_epoch: int, ) -> None: """Audit pass: every filter-permitted syscall after a mutation must be attributable to a grant in the current epoch.""" arg_tuple = (canonicalize(raw_arg),) if not ledger.is_attributed(skill_id, syscall, arg_tuple, current_epoch): raise UnattributedCapabilityError( f"skill {skill_id} invoked permitted syscall {syscall}{arg_tuple} " f"in epoch {current_epoch} with no matching grant; " f"treating as escape" ) ```