# Which invariants prevent AAIF-scoped agent permissions from being silently widened at runtime enforcement points **Date of Record:** 2026 November 11 **Scope:** Applies to runtime policy enforcement points (PEPs) that mediate tool calls, filesystem access, network egress, and credential use for agents operating under an Agent Authorization and Identity Framework (AAIF) grant. Does not apply to static authorization-time review, nor to PEPs that re-derive authorization from the AAIF grant on every call without a cached decision. ## Core Distinction The distinction that matters is between **authorization-time scope** (the set of permissions written into the AAIF grant) and **enforcement-time scope** (the set of operations the PEP will actually permit). These are not the same set, and the gap between them is where least privilege dies. Most teams reason about AAIF permissions as if the grant is a ceiling. It is not. The grant is a *document*; the PEP is the *interpreter*. A PEP that resolves scope by any mechanism other than direct structural containment — substring matching on tool names, prefix matching on resource paths, inheritance from a parent agent's token, or fallback to a broader default when a rule is missing — will admit operations that no one authorized. The grant still looks correct in the audit log. The agent still appears scoped. The widening happened in the interpreter, not the document. The invariant that must hold is **containment, not intersection**: every operation the PEP admits must be a member of the grant's explicitly enumerated set, tested by structural equality against the grant's own terms, never by pattern matching that can be satisfied by a term the grant did not contain. ## What Was Observed A research agent was granted AAIF scope `fs.read:/data/experiments/*` and `tool:summarize`. The PEP implemented path scoping with a prefix check after normalizing the requested path. The agent requested `/data/experiments/../secrets/keys.json`. Normalization collapsed this to `/data/secrets/keys.json`; the prefix check then ran against the *original* unnormalized string in one code path and the *normalized* string in another, and the two disagreed. The read was admitted. The grant had never contained `/data/secrets`. In a second incident, a PEP cached the resolved permission set per agent session and refreshed it on a 15-minute timer. A grant was revoked at minute 2; the agent continued operating under the cached decision until minute 15. The revocation was recorded, the audit trail was complete, and the agent exceeded its scope for 13 minutes with no anomaly visible in the grant record. In a third, a tool name was scoped as `tool:summarize`. The PEP matched with `startswith`, so `tool:summarize_and_export_full_corpus` matched. The grant contained one tool; the agent invoked another. In all three cases the grant was correct, the audit log was correct, and the actual permitted operation set was wider than the grant. The failure was invisible to every system that inspected the grant rather than the enforcement decision. ## Condition of Validity The containment invariant holds only under all of the following conditions. If any fails, the PEP is not enforcing the grant and must be treated as unenforcing: 1. **Canonicalization precedes comparison, and comparison uses the canonical form on both sides.** The grant's terms are canonicalized at load time; the request is canonicalized at call time; the comparison operates on canonical forms only. No code path compares a raw form against a canonical form. 2. **Matching is exact on the grant's own grammar.** Tool names, resource paths, and action verbs are matched by full structural equality against enumerated terms, or by an explicitly declared wildcard that the grant author wrote. Implicit prefix, substring, or glob semantics are forbidden — they are a widening surface, not a convenience. 3. **No inheritance across agent boundaries.** A child agent or delegated sub-agent does not inherit the parent's resolved scope unless the grant explicitly enumerates the child's terms. Delegation is a new grant, not a token re-use. 4. **No fallback on missing rules.** If no rule matches, the decision is deny. A default-allow, a "closest match," or a "most specific rule wins" resolution that can select a broader rule than the grant intended all violate containment. 5. **Revocation is honored at decision time, not at cache expiry.** Either decisions are re-derived per call, or any cached decision carries a revocation generation number that is checked before the decision is returned. A cache without a revocation check is a time-bounded widening. ## Decision Boundary The boundary is this: **the PEP may only admit an operation if it can produce the exact grant term that authorizes it.** Not a term that "covers" it, not a term that "matches" it under some resolution algorithm — the term itself, as written in the grant, after canonicalization on both sides. If the PEP cannot point to the authorizing term, it must deny, and it must deny *loudly*: the denial is evidence that either the grant is incomplete or the request is out of scope, and both are operationally significant. A silent deny that looks like a normal refusal erases the signal that an agent attempted an out-of-scope operation, which is exactly the signal least-privilege monitoring depends on. The corollary: a PEP that cannot distinguish "denied because out of scope" from "denied because malformed" from "denied because revocation raced the decision" is not auditable. Each denial class must be distinct in the decision record, because the remediation differs — grant amendment, request rejection, or cache invalidation respectively. ## Operational Effect When containment holds, the enforcement-time scope equals the authorization-time scope, and the AAIF grant becomes a trustworthy statement of what the agent can do. Audit queries against the grant answer the question "what was this agent permitted?" correctly, and anomaly detection on denial records surfaces scope-probing behavior. When containment fails, the grant becomes a *lower bound* on what the agent could do, and every downstream consumer of the grant — auditors, incident responders, blast-radius estimators — is reasoning from a false premise. The failure is not loud; it is a quiet widening that leaves the grant record intact. The only reliable detector is a decision-log reconciliation: for every admitted operation, the authorizing grant term must be present in the grant at the time of the decision. That reconciliation is the operational test of the invariant, and it should run continuously rather than at review time. The practical rule: treat every PEP as untrusted until it has been shown to deny a request whose canonical form is one character outside a granted term, and to deny it with a distinct out-of-scope decision code. A PEP that passes that test is enforcing; a PEP that has never been tested that way is assumed to be widening. ```python import posixpath from dataclasses import dataclass, field from typing import FrozenSet class ScopeContainmentError(RuntimeError): """Raised when a PEP admits or fails to deny an operation outside the AAIF grant.""" pass @dataclass(frozen=True) class AAIFGrant: agent_id: str terms: FrozenSet[str] = field(default_factory=frozenset) revocation_generation: int = 0 def canonicalize(term: str) -> str: # Structural canonical form: no trailing slash, no '..' segments, no case drift. if term.startswith("fs."): _, rest = term.split(".", 1) action, _, path = rest.partition(":") return f"fs.{action}:{posixpath.normpath(path)}" return term.strip() class EnforcementPoint: def __init__(self, grant: AAIFGrant): self._grant = grant self._canonical_terms = frozenset(canonicalize(t) for t in grant.terms) self._decision_generation = grant.revocation_generation def _authorizing_term(self, request: str) -> str | None: canon = canonicalize(request) # Exact structural membership only. No prefix, substring, or glob fallback. return canon if canon in self._canonical_terms else None def admit(self, request: str, current_revocation_generation: int) -> None: if current_revocation_generation != self._decision_generation: raise ScopeContainmentError( f"revocation generation advanced for agent {self._grant.agent_id}; " f"cached decision is stale and must be re-derived" ) term = self._authorizing_term(request) if term is None: # Distinct out-of-scope denial; never a silent or generic refusal. raise ScopeContainmentError( f"agent {self._grant.agent_id} requested {canonicalize(request)!r}, " f"which is not an enumerated term in the AAIF grant" ) return None def reconcile_decision_log(grant: AAIFGrant, admitted_requests: list[str]) -> None: """Continuous invariant check: every admitted op must map to a grant term.""" canonical_terms = frozenset(canonicalize(t) for t in grant.terms) for req in admitted_requests: if canonicalize(req) not in canonical_terms: raise ScopeContainmentError( f"decision log contains admitted operation {canonicalize(req)!r} " f"with no authorizing term in grant for agent {grant.agent_id}" ) ```