F054: CEL Expression Guardrails
Status: Shipped (on main, unreleased — targets v0.16.0)
Priority: P1 — Fixes MCP guardrail context gap, enables flexible rules
Dependencies: None (replaces existing guardrail evaluator)
Origin: Acteon Action Gateway (CEL for rule evaluation), Nous 004.1
Summary
Section titled “Summary”Replace the JSONB condition matching in checkGuardrails with Google’s Common Expression Language (CEL). This makes guardrails composable, readable, and able to access any field in the action context — including the context dict that MCP clients currently cannot pass.
Problem
Section titled “Problem”Current: Rigid JSONB Matching
Section titled “Current: Rigid JSONB Matching”{"stakes": "high", "confidence_lt": 0.5}- Only 4 hardcoded condition keys recognized
- Unknown keys silently dropped by
ActionContext.from_dict() - MCP
CheckActionInputschema has nocontextfield - Category-specific guardrails (e.g.,
require-architecture-review) always block through MCP because there’s no way to passarchitecture_review=true
Proposed: CEL Expressions
Section titled “Proposed: CEL Expressions”action.stakes == 'high' && action.confidence < 0.5action.category == 'architecture' && !action.context.architecture_reviewsize(action.tags) == 0 && action.stakes in ['high', 'critical']- Any field accessible via dot notation
- Custom fields via
action.context.* - Boolean logic, comparisons, list/map functions
- Sandboxed — no side effects, no I/O
Changes
Section titled “Changes”1. Guardrail Storage
Section titled “1. Guardrail Storage”Guardrail condition column (JSONB) supports three formats:
| Format | Example | Notes |
|---|---|---|
| CEL string | "action.stakes == 'high'" |
Preferred |
Dict with cel key |
{"cel": "action.stakes == 'high'"} |
Alternative |
| Legacy JSONB | {"stakes": "high", "confidence_lt": 0.5} |
Auto-converted to CEL |
2. Evaluation Engine
Section titled “2. Evaluation Engine”New CelGuardrailEvaluator class:
- Compiles CEL expressions once, caches programs
- Builds activation context from action parameters
- Evaluates all active guardrails, returns blocked/warned
- Fails open on eval errors (log + skip, don’t block)
3. CEL Activation Context
Section titled “3. CEL Activation Context”{ "action": { "description": "...", "stakes": "high", "confidence": 0.85, "category": "architecture", "tags": ["deployment", "infrastructure"], "reason_count": 2, "pattern": "...", "quality_score": 0.8, "has_pattern": true, "has_tags": true, "context": { "architecture_review": true, "code_review": true, # ... any custom key-value pairs } }}4. MCP Schema Update
Section titled “4. MCP Schema Update”Add context field to CheckActionInput:
class CheckActionInput(BaseModel): description: str stakes: str = "medium" confidence: float = 0.8 category: str | None = None context: dict | None = None # NEW — arbitrary key-value pairs for CELUpdate _build_guardrails_params in mcp_server.py to forward context.
5. Migration of Existing Guardrails
Section titled “5. Migration of Existing Guardrails”Auto-convert legacy JSONB to CEL at evaluation time:
| JSONB Key | CEL Expression |
|---|---|
"stakes": "high" |
action.stakes == 'high' |
"confidence_lt": 0.5 |
action.confidence < 0.5 |
"reason_count_lt": 1 |
action.reason_count < 1 |
"quality_lt": 0.5 |
action.quality_score < 0.5 |
"category": "tooling" |
action.category == 'tooling' |
Existing guardrails continue to work without manual migration.
Example Guardrails (CEL)
Section titled “Example Guardrails (CEL)”Current guardrails rewritten
Section titled “Current guardrails rewritten”# no-high-stakes-low-confidenceaction.stakes == 'high' && action.confidence < 0.5
# no-trading-strategy-without-backtestaction.category == 'tooling' && action.description.contains('trading') && !action.context.backtest_completed
# require-code-review-toolingaction.category == 'tooling' && !action.context.code_review
# require-architecture-reviewaction.category == 'architecture' && !action.context.architecture_review
# low-quality-recordingaction.quality_score < 0.5
# require-deliberation (check for reasoning)action.reason_count < 1 && action.stakes in ['medium', 'high', 'critical']New guardrails enabled by CEL
Section titled “New guardrails enabled by CEL”# Block high-stakes decisions at night (context.hour set by caller)action.stakes == 'critical' && action.context.hour >= 22
# Require 2+ reasons for high-stakesaction.stakes == 'high' && action.reason_count < 2
# Block decisions without tags AND pattern!action.has_tags && !action.has_pattern && action.stakes != 'low'
# Category-specific confidence floorsaction.category == 'security' && action.confidence < 0.7Dependency
Section titled “Dependency”cel-python >= 0.4, < 1.0Pure Python, maintained by Cloud Custodian (Google-backed). ~10KB, minimal transitive deps.
Files Changed
Section titled “Files Changed”| File | Change |
|---|---|
a2a/cstp/guardrails_service.py |
New CelGuardrailEvaluator, replace _matches() |
a2a/cstp/models.py |
Add context field to ActionContext |
a2a/mcp_schemas.py |
Add context field to CheckActionInput |
a2a/mcp_server.py |
Forward context in _build_guardrails_params |
pyproject.toml |
Add cel-python dependency |
tests/test_f054_cel_guardrails.py |
New CEL tests + verify legacy compat |
As Shipped
Section titled “As Shipped”Two details differ from the plan above and are worth knowing when authoring rules:
condition:disables the flat format. When aconditionkey is present — string,{"cel": …}, or legacy dict — the guardrail is evaluated only through CEL, and siblingcondition_*/requires_*keys are ignored. The nestedrequires:dict is still parsed.cel-pythonis a core dependency, not an extra. If it is missing, the import fails softly and every CEL guardrail is skipped rather than erroring at startup.
See the guardrails authoring guide for the full activation field reference.
Backward Compatibility
Section titled “Backward Compatibility”- Full backward compatibility — legacy JSONB conditions auto-convert to CEL
- No database migration needed — existing condition values work as-is
- MCP clients without
context— work exactly as before (context defaults to empty dict) - Dashboard — guardrail display unchanged (condition shown as-is)
Testing
Section titled “Testing”| Test | What |
|---|---|
| Legacy JSONB still works | Auto-conversion produces correct CEL |
| CEL string condition | Direct expression evaluation |
| CEL dict condition | {"cel": "..."} format |
| Context access via CEL | action.context.custom_field works |
| MCP context forwarding | CheckActionInput.context reaches evaluator |
| Invalid CEL fails open | Bad syntax → no block, warning logged |
| Program caching | Same expression compiled once |
| Complex expressions | AND/OR/NOT/in/contains/size |
| Null handling | Missing fields don’t crash |
Design Decisions
Section titled “Design Decisions”D1: Fail open
Section titled “D1: Fail open”Invalid expressions don’t block. Better to miss a guardrail than brick all decisions. Errors logged for admin to fix.
D2: action namespace (not decision)
Section titled “D2: action namespace (not decision)”CE uses “action” terminology in guardrails. Nous uses “decision”. Each project uses its own convention. CE expressions use action.*, Nous uses decision.*.
D3: No CEL in hot path
Section titled “D3: No CEL in hot path”CEL evaluation is only in checkGuardrails, not in queryDecisions or recordDecision. The hot path stays fast.
D4: Context dict is the escape hatch
Section titled “D4: Context dict is the escape hatch”Instead of adding fields to ActionContext for every new guardrail need, context is an open map. CEL makes it usable without schema changes.
Impact
Section titled “Impact”- Fixes MCP guardrail gap — MCP clients can now pass
contextdict - Enables user-defined guardrails — no code changes needed for new conditions
- Dashboard potential — CEL expressions could be edited in the dashboard UI
- Shared pattern with Nous — both projects use CEL for guardrails