The CEL prefilter answers one question: is this tool or this model visible to this key right now? It runs on every tools/list entry, every model check on /v1/*, and again immediately before every tools/call. It never decides permission. The implementation is apps/gateway/crates/obol-policy/src/cel/. It uses the upstream cel-interpreter crate (0.10) with a fixed, eagerly built context — not a vendored fork and not a lazily resolved request context (ADR-0011).

Built-in expressions

Three expressions compile once at startup and are combined in Rust for tools, and one for models:
The destructive-hiding clause is what serves invariant 6. A glob grant such as stripe.* hides destructive tools from tools/list in prod; only a verbatim allowlist entry — stripe.create_refund — reveals one. The tool is still callable if the key names it and Cedar permits the call; hiding is about discovery.

The context

The context is fixed. Nothing else is in scope, for built-in and customer expressions alike. explicit is computed in Rust because CEL has no “matched exactly, not by glob” primitive. tools_mode is "full" or "progressive". One custom function is registered: glob(pattern, value).
The grammar is case-sensitive, an empty pattern matches nothing, and a * in the middle of a pattern is a literal asterisk.

Failing closed

Every path that is not an unambiguous true results in hidden:
  • a parse error, a runtime error, or an undeclared variable
  • a non-boolean result
  • a missing tool or a missing model
  • an empty allowlist
Prefilter::compile additionally wraps the parser in catch_unwind, because the antlr-generated parser in cel-interpreter 0.10 panics on some malformed input rather than returning an error. A panic is mapped to a policy error, so a bad expression can never take a gateway replica down.
That guard depends on the default panic = "unwind" profile. Setting panic = "abort" anywhere in the gateway workspace reopens the crash vector.
Programs and the function registry are built once and shared; per-call cost is roughly 8 µs in a debug build, asserted by a test that runs 10,000 evaluations in under a second.

CEL or Cedar?

Belongs in CEL

Tool-name allowlists and globs. Environment matching. Hiding destructive tools from discovery. Model allowlists. Anything that is a cheap predicate over data already in memory.

Belongs in Cedar

Anything about permission. Argument conditions such as a refund cap. Approval gates. Per-agent grants. Anything a security reviewer needs to read as a rule, and anything that must appear on a receipt.
The rule is short: CEL decides visibility, Cedar decides permission. Tiny expressions only — never business rules. A CEL decision is not recorded as a matched policy on a receipt; only Cedar decisions are. If you find yourself wanting request-body or header predicates in a visibility rule, that is a Cedar context question first.

Customer expressions

Prefilter::compile and Prefilter::eval exist so a customer-authored visibility expression can run against exactly the context above and nothing more, validated at publish time by control. The same fail-closed rules apply. Related: Policy overview, Cedar authorization, MCP surface, Virtual keys, Invariants.