use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use cel_interpreter::objects::{Key, Map, Value};
use cel_interpreter::{Context, Program};
use super::{Guardrail, GuardrailContext, GuardrailDecision, GuardrailStage};
const CEL_EVAL_ERROR_CODE: u32 = 4001;
const MAX_CEL_EXPRESSION_LEN: usize = 4096;
const MAX_CEL_NESTING_DEPTH: usize = 64;
const MAX_CEL_OPERATOR_TOKENS: usize = 256;
#[cfg_attr(alef, alef(skip))]
#[derive(Debug, Clone)]
pub enum CelAction {
Block {
code: u32,
reason: String,
},
Mutate {
new_payload: serde_json::Value,
},
}
#[cfg_attr(alef, alef(skip))]
pub struct CelGuardrail {
guardrail_name: &'static str,
program: Program,
on_true: CelAction,
stages: &'static [GuardrailStage],
fail_open: bool,
}
#[cfg_attr(alef, alef(skip))]
#[derive(Debug, thiserror::Error)]
pub enum CelCompileError {
#[error("CEL expression exceeds maximum length of {max} bytes (got {actual})")]
TooLong {
actual: usize,
max: usize,
},
#[error("CEL expression exceeds maximum nesting depth of {max} (got {actual})")]
TooDeep {
actual: usize,
max: usize,
},
#[error("CEL expression exceeds maximum operator count of {max} (got {actual})")]
TooManyOperators {
actual: usize,
max: usize,
},
#[error("invalid CEL expression: {0}")]
Invalid(String),
}
fn validate_expression(expression: &str) -> Result<(), CelCompileError> {
if expression.len() > MAX_CEL_EXPRESSION_LEN {
return Err(CelCompileError::TooLong {
actual: expression.len(),
max: MAX_CEL_EXPRESSION_LEN,
});
}
let mut depth: usize = 0;
let mut operators: usize = 0;
for ch in expression.chars() {
match ch {
'(' | '[' | '{' => {
depth += 1;
if depth > MAX_CEL_NESTING_DEPTH {
return Err(CelCompileError::TooDeep {
actual: depth,
max: MAX_CEL_NESTING_DEPTH,
});
}
}
')' | ']' | '}' => {
depth = depth.saturating_sub(1);
}
'!' | '&' | '|' | '+' | '-' | '*' | '/' | '%' | '<' | '>' | '=' | '?' => {
operators += 1;
if operators > MAX_CEL_OPERATOR_TOKENS {
return Err(CelCompileError::TooManyOperators {
actual: operators,
max: MAX_CEL_OPERATOR_TOKENS,
});
}
}
_ => {}
}
}
Ok(())
}
impl CelGuardrail {
pub fn new(
name: &'static str,
expression: &str,
on_true: CelAction,
stages: &'static [GuardrailStage],
) -> Result<Self, CelCompileError> {
validate_expression(expression)?;
let compiled = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| Program::compile(expression)));
let program = match compiled {
Ok(Ok(program)) => program,
Ok(Err(parse_errors)) => return Err(CelCompileError::Invalid(parse_errors.to_string())),
Err(_panic) => {
return Err(CelCompileError::Invalid(format!(
"parser panicked on expression {expression:?}"
)));
}
};
Ok(Self {
guardrail_name: name,
program,
on_true,
stages,
fail_open: false,
})
}
#[must_use]
pub fn with_fail_open(mut self, fail_open: bool) -> Self {
self.fail_open = fail_open;
self
}
}
impl Guardrail for CelGuardrail {
fn name(&self) -> &'static str {
self.guardrail_name
}
fn supported_stages(&self) -> &'static [GuardrailStage] {
self.stages
}
fn check<'a>(
&'a self,
stage: GuardrailStage,
ctx: &'a GuardrailContext<'a>,
) -> Pin<Box<dyn Future<Output = GuardrailDecision> + Send + 'a>> {
Box::pin(async move {
let mut cel_ctx = Context::default();
cel_ctx.add_variable_from_value("request", json_value_to_cel(ctx.request));
let response_val = ctx.response.map(json_value_to_cel).unwrap_or_else(|| {
Value::Map(Map {
map: Arc::new(HashMap::new()),
})
});
cel_ctx.add_variable_from_value("response", response_val);
let chunk_str = ctx.chunk.unwrap_or("").to_string();
cel_ctx.add_variable_from_value("chunk", Value::String(Arc::new(chunk_str)));
cel_ctx.add_variable_from_value("metadata", metadata_to_cel(ctx.metadata));
match self.program.execute(&cel_ctx) {
Ok(Value::Bool(true)) => match &self.on_true {
CelAction::Block { code, reason } => GuardrailDecision::Block {
reason: reason.clone(),
code: *code,
},
CelAction::Mutate { new_payload } => GuardrailDecision::Mutate {
new_payload: new_payload.clone(),
},
},
Ok(Value::Bool(false)) => GuardrailDecision::Allow,
Ok(non_bool) => {
tracing::error!(
guardrail = self.guardrail_name,
stage = ?stage,
result = ?non_bool,
"CEL expression returned non-bool value; \
defaulting to fail-closed (Block/4001) — \
set fail_open=true to suppress"
);
if self.fail_open {
GuardrailDecision::Allow
} else {
GuardrailDecision::Block {
reason: "policy evaluation error".to_owned(),
code: CEL_EVAL_ERROR_CODE,
}
}
}
Err(e) => {
tracing::error!(
guardrail = self.guardrail_name,
stage = ?stage,
error = %e,
"CEL expression evaluation error; \
defaulting to fail-closed (Block/4001) — \
set fail_open=true to suppress"
);
if self.fail_open {
GuardrailDecision::Allow
} else {
GuardrailDecision::Block {
reason: "policy evaluation error".to_owned(),
code: CEL_EVAL_ERROR_CODE,
}
}
}
}
})
}
}
fn json_value_to_cel(value: &serde_json::Value) -> Value {
match value {
serde_json::Value::Null => Value::Null,
serde_json::Value::Bool(b) => Value::Bool(*b),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Value::Int(i)
} else if let Some(f) = n.as_f64() {
Value::Float(f)
} else {
Value::Null
}
}
serde_json::Value::String(s) => Value::String(Arc::new(s.clone())),
serde_json::Value::Array(arr) => {
let items: Vec<Value> = arr.iter().map(json_value_to_cel).collect();
Value::List(Arc::new(items))
}
serde_json::Value::Object(obj) => {
let mut map: HashMap<Key, Value> = HashMap::new();
for (key, val) in obj {
map.insert(Key::String(Arc::new(key.clone())), json_value_to_cel(val));
}
Value::Map(Map { map: Arc::new(map) })
}
}
}
fn metadata_to_cel(metadata: &HashMap<String, String>) -> Value {
let mut map: HashMap<Key, Value> = HashMap::new();
for (key, val) in metadata {
map.insert(Key::String(Arc::new(key.clone())), Value::String(Arc::new(val.clone())));
}
Value::Map(Map { map: Arc::new(map) })
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
use crate::guardrail::{GuardrailContext, GuardrailStage};
static INPUT_STAGES: &[GuardrailStage] = &[GuardrailStage::Input];
fn meta_with(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
}
#[tokio::test]
async fn cel_guardrail_blocks_when_expression_is_true() {
let meta = meta_with(&[("tier", "free")]);
let req = serde_json::json!({ "model": "gpt-4o" });
let guardrail = CelGuardrail::new(
"gpt4o-premium-only",
r#"request.model == "gpt-4o" && metadata.tier != "premium""#,
CelAction::Block {
code: 1300,
reason: "gpt-4o requires premium tier".into(),
},
INPUT_STAGES,
)
.expect("valid CEL expression");
let ctx = GuardrailContext {
request: &req,
response: None,
chunk: None,
metadata: &meta,
};
let decision = guardrail.check(GuardrailStage::Input, &ctx).await;
match decision {
GuardrailDecision::Block { code, reason } => {
assert_eq!(code, 1300);
assert!(reason.contains("premium"), "reason should mention premium tier");
}
other => panic!("expected Block, got {other:?}"),
}
}
#[tokio::test]
async fn cel_guardrail_allows_when_expression_is_false() {
let meta = meta_with(&[("tier", "premium")]);
let req = serde_json::json!({ "model": "gpt-4o" });
let guardrail = CelGuardrail::new(
"gpt4o-premium-only",
r#"request.model == "gpt-4o" && metadata.tier != "premium""#,
CelAction::Block {
code: 1300,
reason: "gpt-4o requires premium tier".into(),
},
INPUT_STAGES,
)
.expect("valid CEL expression");
let ctx = GuardrailContext {
request: &req,
response: None,
chunk: None,
metadata: &meta,
};
let decision = guardrail.check(GuardrailStage::Input, &ctx).await;
assert!(decision.is_allow(), "premium tier should be allowed");
}
#[tokio::test]
async fn cel_guardrail_allows_when_model_does_not_match() {
let meta = meta_with(&[("tier", "free")]);
let req = serde_json::json!({ "model": "gpt-3.5-turbo" });
let guardrail = CelGuardrail::new(
"gpt4o-premium-only",
r#"request.model == "gpt-4o" && metadata.tier != "premium""#,
CelAction::Block {
code: 1300,
reason: "gpt-4o requires premium tier".into(),
},
INPUT_STAGES,
)
.expect("valid CEL expression");
let ctx = GuardrailContext {
request: &req,
response: None,
chunk: None,
metadata: &meta,
};
let decision = guardrail.check(GuardrailStage::Input, &ctx).await;
assert!(
decision.is_allow(),
"non-gpt-4o model should be allowed regardless of tier"
);
}
#[tokio::test]
async fn cel_guardrail_returns_error_for_invalid_expression() {
let result = CelGuardrail::new(
"broken",
"this is not valid !!! CEL $$$",
CelAction::Block {
code: 1399,
reason: "test".into(),
},
INPUT_STAGES,
);
assert!(result.is_err(), "invalid CEL should fail at construction");
}
#[tokio::test]
async fn cel_guardrail_simple_boolean_true_expression_blocks() {
let meta = HashMap::new();
let req = serde_json::json!({});
let guardrail = CelGuardrail::new(
"always-block",
"true",
CelAction::Block {
code: 1399,
reason: "always blocked".into(),
},
INPUT_STAGES,
)
.expect("valid CEL");
let ctx = GuardrailContext {
request: &req,
response: None,
chunk: None,
metadata: &meta,
};
let decision = guardrail.check(GuardrailStage::Input, &ctx).await;
assert!(decision.is_block());
}
#[tokio::test]
async fn cel_guardrail_simple_boolean_false_expression_allows() {
let meta = HashMap::new();
let req = serde_json::json!({});
let guardrail = CelGuardrail::new(
"never-block",
"false",
CelAction::Block {
code: 1399,
reason: "never blocked".into(),
},
INPUT_STAGES,
)
.expect("valid CEL");
let ctx = GuardrailContext {
request: &req,
response: None,
chunk: None,
metadata: &meta,
};
let decision = guardrail.check(GuardrailStage::Input, &ctx).await;
assert!(decision.is_allow());
}
#[tokio::test]
async fn cel_guardrail_eval_error_defaults_to_block() {
let meta = HashMap::new();
let req = serde_json::json!({ "model": "gpt-4o" });
let guardrail = CelGuardrail::new(
"undeclared-var-guardrail",
"undeclared_var == true",
CelAction::Block {
code: 1500,
reason: "blocked by policy".into(),
},
INPUT_STAGES,
)
.expect("valid CEL expression");
let ctx = GuardrailContext {
request: &req,
response: None,
chunk: None,
metadata: &meta,
};
let decision = guardrail.check(GuardrailStage::Input, &ctx).await;
match decision {
GuardrailDecision::Block { code, reason } => {
assert_eq!(code, CEL_EVAL_ERROR_CODE, "eval error must use code 4001");
assert_eq!(
reason, "policy evaluation error",
"eval error reason must be opaque; got: {reason}"
);
assert!(
!reason.contains("guardrail evaluation error"),
"old verbose reason must not appear in caller response; got: {reason}"
);
}
other => panic!("expected Block(4001) on eval error (fail-closed default), got {other:?}"),
}
}
#[tokio::test]
async fn cel_guardrail_eval_error_with_fail_open_returns_allow() {
let meta = HashMap::new();
let req = serde_json::json!({ "model": "gpt-4o" });
let guardrail = CelGuardrail::new(
"undeclared-var-fail-open",
"undeclared_var == true",
CelAction::Block {
code: 1500,
reason: "blocked by policy".into(),
},
INPUT_STAGES,
)
.expect("valid CEL expression")
.with_fail_open(true);
let ctx = GuardrailContext {
request: &req,
response: None,
chunk: None,
metadata: &meta,
};
let decision = guardrail.check(GuardrailStage::Input, &ctx).await;
assert!(
decision.is_allow(),
"with_fail_open(true) must return Allow on eval error, got {decision:?}"
);
}
#[tokio::test]
async fn cel_guardrail_non_bool_result_blocks_by_default() {
let meta = HashMap::new();
let req = serde_json::json!({ "model": "gpt-4o" });
let guardrail_default = CelGuardrail::new(
"non-bool-default",
"request.model",
CelAction::Block {
code: 1500,
reason: "blocked by policy".into(),
},
INPUT_STAGES,
)
.expect("valid CEL expression");
let guardrail_fail_open = CelGuardrail::new(
"non-bool-fail-open",
"request.model",
CelAction::Block {
code: 1500,
reason: "blocked by policy".into(),
},
INPUT_STAGES,
)
.expect("valid CEL expression")
.with_fail_open(true);
let ctx = GuardrailContext {
request: &req,
response: None,
chunk: None,
metadata: &meta,
};
let decision_default = guardrail_default.check(GuardrailStage::Input, &ctx).await;
match &decision_default {
GuardrailDecision::Block { code, .. } => {
assert_eq!(*code, CEL_EVAL_ERROR_CODE, "non-bool must use code 4001");
}
other => panic!("expected Block(4001) for non-bool result (fail-closed default), got {other:?}"),
}
let decision_open = guardrail_fail_open.check(GuardrailStage::Input, &ctx).await;
assert!(
decision_open.is_allow(),
"fail_open=true must return Allow for non-bool result, got {decision_open:?}"
);
}
#[tokio::test]
async fn cel_guardrail_compile_error_still_blocks_construction() {
let result = CelGuardrail::new(
"bad-syntax",
"request.model ==",
CelAction::Block {
code: 1399,
reason: "test".into(),
},
INPUT_STAGES,
);
assert!(
result.is_err(),
"malformed CEL expression must fail at construction, not at eval time"
);
}
#[tokio::test]
async fn cel_error_reason_not_leaked_to_caller() {
let meta = HashMap::new();
let req = serde_json::json!({ "model": "gpt-4o" });
let guardrail = CelGuardrail::new(
"leaky-error-guardrail",
"undeclared_internal_var == true",
CelAction::Block {
code: 1500,
reason: "should not appear — error path taken".into(),
},
INPUT_STAGES,
)
.expect("syntactically valid CEL");
let ctx = GuardrailContext {
request: &req,
response: None,
chunk: None,
metadata: &meta,
};
let decision = guardrail.check(GuardrailStage::Input, &ctx).await;
match decision {
GuardrailDecision::Block { code, reason } => {
assert_eq!(code, CEL_EVAL_ERROR_CODE, "eval error must use code 4001");
assert_eq!(
reason, "policy evaluation error",
"reason must be opaque sentinel, not internal error detail; got: {reason:?}"
);
assert!(
!reason.contains("guardrail evaluation error"),
"old verbose reason must not leak to caller; got: {reason:?}"
);
assert!(
!reason.contains("undeclared_internal_var"),
"internal CEL variable name must not leak to caller; got: {reason:?}"
);
}
other => panic!("expected Block(4001) from eval error, got {other:?}"),
}
}
#[tokio::test]
async fn cel_non_bool_reason_not_leaked_to_caller() {
let meta = HashMap::new();
let req = serde_json::json!({ "model": "gpt-4o" });
let guardrail = CelGuardrail::new(
"non-bool-leak-check",
"request.model",
CelAction::Block {
code: 1500,
reason: "should not appear".into(),
},
INPUT_STAGES,
)
.expect("valid CEL expression");
let ctx = GuardrailContext {
request: &req,
response: None,
chunk: None,
metadata: &meta,
};
let decision = guardrail.check(GuardrailStage::Input, &ctx).await;
match decision {
GuardrailDecision::Block { code, reason } => {
assert_eq!(code, CEL_EVAL_ERROR_CODE);
assert_eq!(
reason, "policy evaluation error",
"non-bool reason must be opaque; got: {reason:?}"
);
assert!(
!reason.contains("gpt-4o"),
"CEL result value must not leak to caller; got: {reason:?}"
);
}
other => panic!("expected Block(4001), got {other:?}"),
}
}
#[tokio::test]
async fn cel_guardrail_rejects_deeply_nested_expression_without_reaching_parser() {
let depth = MAX_CEL_NESTING_DEPTH + 1;
let expression = format!("{}true{}", "(".repeat(depth), ")".repeat(depth));
assert!(
expression.len() < MAX_CEL_EXPRESSION_LEN,
"test expression must stay under the length bound so only depth is exercised"
);
let result = CelGuardrail::new(
"too-deep",
&expression,
CelAction::Block {
code: 1399,
reason: "test".into(),
},
INPUT_STAGES,
);
match result {
Err(CelCompileError::TooDeep { actual, max }) => {
assert_eq!(max, MAX_CEL_NESTING_DEPTH);
assert!(actual > max, "reported depth {actual} should exceed the limit {max}");
}
Ok(_) => panic!("expected CelCompileError::TooDeep, got a compiled guardrail"),
Err(other) => panic!("expected CelCompileError::TooDeep, got {other:?}"),
}
}
#[tokio::test]
async fn cel_guardrail_rejects_oversized_expression_without_reaching_parser() {
let filler = "a".repeat(MAX_CEL_EXPRESSION_LEN + 1);
let expression = format!("\"{filler}\" == \"{filler}\"");
let result = CelGuardrail::new(
"too-long",
&expression,
CelAction::Block {
code: 1399,
reason: "test".into(),
},
INPUT_STAGES,
);
match result {
Err(CelCompileError::TooLong { actual, max }) => {
assert_eq!(max, MAX_CEL_EXPRESSION_LEN);
assert!(actual > max, "reported length {actual} should exceed the limit {max}");
}
Ok(_) => panic!("expected CelCompileError::TooLong, got a compiled guardrail"),
Err(other) => panic!("expected CelCompileError::TooLong, got {other:?}"),
}
}
#[tokio::test]
async fn cel_guardrail_rejects_five_thousand_nested_parens() {
let expression = format!("{}true{}", "(".repeat(5000), ")".repeat(5000));
let result = CelGuardrail::new(
"pathological-nesting",
&expression,
CelAction::Block {
code: 1399,
reason: "test".into(),
},
INPUT_STAGES,
);
assert!(
result.is_err(),
"5000 nested parens must be rejected before reaching the CEL parser"
);
}
#[tokio::test]
async fn cel_guardrail_rejects_long_operator_chains_that_bracket_depth_cannot_see() {
for expression in [
"!".repeat(2000) + "true",
"true".to_owned() + &"&&true".repeat(600),
"1".to_owned() + &"+1".repeat(1500),
] {
assert_eq!(
count_brackets(&expression),
0,
"the chain must stay at bracket depth zero, or it would be caught by the \
depth cap and this test would prove nothing"
);
let result = CelGuardrail::new(
"pathological-operator-chain",
&expression,
CelAction::Block {
code: 1399,
reason: "test".into(),
},
INPUT_STAGES,
);
match result {
Err(CelCompileError::TooManyOperators { .. }) => {}
Ok(_) => panic!("an operator chain must be rejected before reaching the CEL parser"),
Err(other) => panic!("expected TooManyOperators, got {other:?}"),
}
}
}
fn count_brackets(expression: &str) -> usize {
expression.chars().filter(|c| matches!(c, '(' | '[' | '{')).count()
}
}