# What structural signals indicate a capability-scoped tool sandbox has silently widened its authority boundary **Date of Record:** 2026 November 8 **Scope:** Applies to agent runtimes that mediate tool calls through capability handles (file descriptors, signed tokens, attenuated delegations) rather than ambient OS credentials. Does not apply to sandboxes that enforce isolation purely at the kernel/VM boundary with no in-process capability layer, where authority widening manifests as a hard fault rather than a silent drift. ## Core Distinction A capability-scoped sandbox fails in two categorically different ways. The **loud failure** is a denied call: the handle lacks the bit, the delegation is expired, the syscall is refused. This is observable, logged, and self-correcting. The **silent failure** is *capability accrual*: the sandbox continues to permit exactly the calls it was asked to permit, but the *set of resources those calls can reach* grows without any corresponding change to the capability grant. The grant is unchanged; the authority is not. The invariant that breaks is not "the sandbox enforces the policy." It is the stronger, easily-assumed invariant: **the authority exercised by a capability is monotonically non-increasing over the lifetime of the sandbox's reference to it.** Tools break this invariant not by escalating their own token but by exploiting *referential* authority — symlinks, hardlinks, mount overlays, `/proc/self/fd` re-entry, environment-variable indirection, or path canonicalization performed *after* the capability check rather than before. ## What Was Observed A tool runtime granted a `ReadFile` capability scoped to a workspace root `/work/agent-7/`. The capability token encoded the root and a read-only bit. The check was performed against the *lexical* path supplied by the tool. The tool was permitted to read `/work/agent-7/scratch/notes.txt`. During the session, a sibling tool with `WriteFile` scoped to `/work/agent-7/scratch/` created a symlink `notes.txt -> ../../../../etc/passwd` (a relative link that, lexically, stays inside the root). The next `ReadFile` call passed the lexical check — the path string began with the root — and the kernel resolved the symlink *after* the capability check, returning `/etc/passwd` contents. No capability was widened. No policy was edited. The `ReadFile` handle still "only" reads within `/work/agent-7/`. Yet the authority exercised by that handle now includes the entire filesystem reachable by relative traversal. The structural signal was present but unlogged: the *resolved* inode was outside the capability's declared root, while the *checked* path was inside it. The gap between check-time and use-time path identity is the boundary. A second instance: a capability scoped to a subprocess environment passed `PATH` through unchanged. A tool invoked `git` by bare name; `PATH` resolution selected a binary from a directory the capability did not cover. The capability granted "run git," but the *identity* of git was decided by ambient state outside the capability's scope. Authority widened by name resolution, not by grant. ## Condition of Validity The non-increasing-authority invariant holds **only if** every capability check is performed on the *same resolved object* that the operation will subsequently act upon, and if no ambient mutable state (environment, cwd, PATH, symlink tables, mount namespace) participates in resolving that object between check and use. This is a time-of-check-to-time-of-use (TOCTOU) condition generalized from memory safety to *namespace* safety. Formally: let `C` be the capability with declared scope `S`. The invariant holds iff for every operation `op`, `resolve(check_path(op)) == resolve(op_path(op))` and `resolve` is a pure function of state frozen at grant time. Any sandbox where `resolve` reads live, tool-writable state violates the condition. Symlinks, hardlinks, `..` traversal, `/proc` re-entry, and PATH lookup are all instances of live `resolve`. ## Decision Boundary Before trusting a capability-scoped sandbox, the operator must decide one question: **is the capability bound to a name or to an object?** If bound to a name (a path string, a command name, an environment key), the sandbox is vulnerable to namespace substitution and must be treated as advisory, not enforcing. If bound to an object (an open file descriptor, an inode number captured at grant time, a resolved absolute path with `O_NOFOLLOW` semantics, an exec'd binary pinned by hash), the invariant holds. The boundary is crossed the moment a tool can *write* to any namespace the sandbox *reads* during resolution. If the tool's write scope and the sandbox's resolution scope intersect — even at a single symlink-creating operation — the least-privilege guarantee is void. The correct decision is to either (a) freeze resolution at grant time by capturing object handles, or (b) prove disjointness of write scope and resolution scope, which is almost never provable in a shared workspace. ## Operational Effect Practically, this means a capability sandbox must be audited not by inspecting grants but by inspecting *resolution*. The observable signal is a divergence between the path/name that was checked and the object that was used. Instrument the sandbox to log the resolved inode (or binary hash, or env value) at use time and compare it against the object captured at grant time. Any divergence is a capability-accrual event, even if the grant is byte-identical. Treat every tool-writable namespace element that participates in resolution as an implicit authority grant, and account for it as such. ```python import os from dataclasses import dataclass class CapabilityAccrualError(RuntimeError): """Raised when a capability's exercised authority exceeds its granted scope.""" pass @dataclass(frozen=True) class Capability: root_inode: int # object identity captured at grant time root_path: str # declared scope, for diagnostics only read_only: bool = True def resolve_inode(path: str) -> int: # Real resolution: follows symlinks, reads live namespace state. return os.stat(path, follow_symlinks=True).st_ino def read_file(cap: Capability, requested_path: str) -> bytes: # The lexical check the naive sandbox performs. if not os.path.abspath(requested_path).startswith(cap.root_path): raise CapabilityAccrualError( f"lexical scope violation: {requested_path} outside {cap.root_path}" ) # The check that actually matters: object identity at use time. actual_inode = resolve_inode(requested_path) if actual_inode != cap.root_inode: raise CapabilityAccrualError( f"capability accrual: {requested_path} resolves to inode " f"{actual_inode}, granted root is {cap.root_inode} " f"({cap.root_path}) — namespace substitution detected" ) with open(requested_path, "rb") as fh: return fh.read() ```