Skip to main content

agentd/
cel.rs

1// SPDX-License-Identifier: Apache-2.0
2//! CEL (Common Expression Language) evaluation — the ONE gated exception to the
3//! zero-dependency moat (`--features cel`, default OFF).
4//!
5//! CEL is used wherever agentd evaluates a deterministic expression over run
6//! data: workflow `{"op":"cel"}` predicates, computed `assign.expr` values,
7//! `infer.check` value constraints, and reactive `{"op":"cel"}` wake conditions.
8//! Its design properties are exactly the requirements here: non-Turing-complete,
9//! no I/O, guaranteed termination — the one form of "code" a model can safely
10//! author and agentd can immediately execute.
11//!
12//! This module is ALWAYS compiled; only its internals are feature-gated. A
13//! non-cel build answers every call with a clear "requires the 'cel' build
14//! feature" error, so authoring surfaces reject CEL at define/parse time
15//! (fail-closed) instead of silently mis-evaluating at run time.
16
17use serde_json::Value;
18
19/// Expression length cap — a routing/shaping expression is a line, not a
20/// program; an oversized one is refused at compile-check time.
21pub const MAX_CEL_EXPR: usize = 4096;
22
23/// The message every entry point returns on a build without the feature.
24pub const FEATURE_MSG: &str = "CEL expressions require the 'cel' build feature";
25
26/// Compile-check an expression (define/parse-time validation): length cap +
27/// full CEL parse. `Err` carries the parser's message for the author.
28pub fn compile_check(expr: &str) -> Result<(), String> {
29    if expr.trim().is_empty() {
30        return Err("empty CEL expression".into());
31    }
32    if expr.len() > MAX_CEL_EXPR {
33        return Err(format!(
34            "CEL expression is {} bytes (max {MAX_CEL_EXPR})",
35            expr.len()
36        ));
37    }
38    #[cfg(feature = "cel")]
39    {
40        imp::compile(expr).map(|_| ())
41    }
42    #[cfg(not(feature = "cel"))]
43    {
44        Err(FEATURE_MSG.into())
45    }
46}
47
48/// Evaluate an expression to a BOOLEAN with the given variables in scope
49/// (each `(name, value)` becomes a top-level identifier). A non-bool result is
50/// an error — a predicate must decide, not coerce.
51pub fn eval_bool(expr: &str, vars: &[(&str, &Value)]) -> Result<bool, String> {
52    #[cfg(feature = "cel")]
53    {
54        match imp::eval(expr, vars)? {
55            cel_interpreter::Value::Bool(b) => Ok(b),
56            other => Err(format!(
57                "CEL expression returned {:?}, want bool",
58                other.type_of()
59            )),
60        }
61    }
62    #[cfg(not(feature = "cel"))]
63    {
64        let _ = (expr, vars);
65        Err(FEATURE_MSG.into())
66    }
67}
68
69/// Evaluate an expression to a JSON VALUE with the given variables in scope —
70/// the computed-`assign` path.
71pub fn eval_value(expr: &str, vars: &[(&str, &Value)]) -> Result<Value, String> {
72    #[cfg(feature = "cel")]
73    {
74        imp::eval(expr, vars)?
75            .json()
76            .map_err(|e| format!("CEL result is not JSON-representable: {e}"))
77    }
78    #[cfg(not(feature = "cel"))]
79    {
80        let _ = (expr, vars);
81        Err(FEATURE_MSG.into())
82    }
83}
84
85/// Convenience: a blackboard-shaped variable list (`BTreeMap<String, Value>` →
86/// the `(name, value)` slice shape the eval fns take).
87pub fn vars_of(map: &std::collections::BTreeMap<String, Value>) -> Vec<(&str, &Value)> {
88    map.iter().map(|(k, v)| (k.as_str(), v)).collect()
89}
90
91#[cfg(feature = "cel")]
92mod imp {
93    use serde_json::Value;
94
95    /// Compile an expression. The upstream parser (`antlr4rust`) can PANIC on
96    /// some malformed inputs (an unfinished binary expression such as `1 +`
97    /// hits an `unreachable!` in its generated tree walker); a config/workflow
98    /// author's typo must never take the process down, so the parse runs
99    /// under `catch_unwind` and reports a parse error instead.
100    pub fn compile(expr: &str) -> Result<cel_interpreter::Program, String> {
101        let owned = expr.to_string();
102        match std::panic::catch_unwind(move || cel_interpreter::Program::compile(&owned)) {
103            Ok(r) => r.map_err(|e| format!("CEL parse: {e}")),
104            Err(_) => Err("CEL parse: malformed expression (the parser rejected it)".into()),
105        }
106    }
107
108    /// Convert JSON → CEL with CANONICAL number typing: JSON has one number
109    /// type, CEL has three (Int/UInt/Float) that do not mix in arithmetic or
110    /// comparison. The default Serialize-based conversion maps a non-negative
111    /// integer to UInt — making `count + 1` a type error against the Int
112    /// literal. Normalizing every i64-fitting integer to Int (else Float) makes
113    /// expressions over JSON data behave the way their authors expect.
114    fn to_cel(v: &Value) -> cel_interpreter::Value {
115        use cel_interpreter::Value as C;
116        use std::sync::Arc;
117        match v {
118            Value::Null => C::Null,
119            Value::Bool(b) => C::Bool(*b),
120            Value::Number(n) => {
121                if let Some(i) = n.as_i64() {
122                    C::Int(i)
123                } else if let Some(f) = n.as_f64() {
124                    C::Float(f)
125                } else {
126                    C::Null
127                }
128            }
129            Value::String(s) => C::String(Arc::new(s.clone())),
130            Value::Array(a) => C::List(Arc::new(a.iter().map(to_cel).collect())),
131            Value::Object(o) => {
132                let map: std::collections::HashMap<String, C> =
133                    o.iter().map(|(k, v)| (k.clone(), to_cel(v))).collect();
134                C::Map(map.into())
135            }
136        }
137    }
138
139    pub fn eval(expr: &str, vars: &[(&str, &Value)]) -> Result<cel_interpreter::Value, String> {
140        let program = compile(expr)?;
141        let mut ctx = cel_interpreter::Context::default();
142        for (name, value) in vars {
143            ctx.add_variable_from_value(name.to_string(), to_cel(value));
144        }
145        // Same panic guard on evaluation (defensive: the interpreter is a
146        // third-party dependency running author-supplied expressions).
147        match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| program.execute(&ctx))) {
148            Ok(r) => r.map_err(|e| format!("CEL eval: {e}")),
149            Err(_) => Err("CEL eval: the interpreter failed on this expression".into()),
150        }
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use serde_json::json;
158
159    #[test]
160    fn empty_and_oversized_expressions_are_refused() {
161        assert!(compile_check("").is_err());
162        assert!(compile_check(&"1 + ".repeat(2000)).is_err());
163    }
164
165    #[cfg(feature = "cel")]
166    mod with_cel {
167        use super::*;
168
169        #[test]
170        fn compile_check_accepts_valid_and_names_parse_errors() {
171            assert!(compile_check("a.b >= 3 && c in ['x','y']").is_ok());
172            let e = compile_check("a >=< 3").unwrap_err();
173            assert!(e.contains("CEL parse"), "{e}");
174        }
175
176        #[test]
177        fn eval_bool_computes_arithmetic_and_macros_over_variables() {
178            let a = json!({"count": 7, "items": [{"s": "ok"}, {"s": "bad"}]});
179            let b = json!({"limit": 5});
180            let vars = vec![("a", &a), ("b", &b)];
181            assert!(eval_bool("a.count + 1 > b.limit * 1", &vars).unwrap());
182            assert!(eval_bool("a.items.exists(i, i.s == 'bad')", &vars).unwrap());
183            assert!(eval_bool("a.items.filter(i, i.s == 'ok').size() == 1", &vars).unwrap());
184            // Non-bool result is an error, not a coercion.
185            assert!(eval_bool("a.count", &vars).is_err());
186            // An undeclared reference is an eval error (callers fail closed).
187            assert!(eval_bool("ghost > 1", &vars).is_err());
188        }
189
190        #[test]
191        fn eval_value_shapes_json() {
192            let scan = json!({"items": [{"id": 1, "ok": true}, {"id": 2, "ok": false}, {"id": 3, "ok": true}]});
193            let vars = vec![("scan", &scan)];
194            let v = eval_value("scan.items.filter(i, i.ok).map(i, i.id)", &vars).unwrap();
195            assert_eq!(v, json!([1, 3]));
196            let v = eval_value(
197                "{'total': scan.items.size(), 'first': scan.items[0].id}",
198                &vars,
199            )
200            .unwrap();
201            assert_eq!(v, json!({"total": 3, "first": 1}));
202        }
203    }
204
205    #[cfg(not(feature = "cel"))]
206    #[test]
207    fn without_the_feature_every_entry_point_names_it() {
208        let v = json!(1);
209        let vars = vec![("a", &v)];
210        assert!(compile_check("a > 0").unwrap_err().contains("'cel'"));
211        assert!(eval_bool("a > 0", &vars).unwrap_err().contains("'cel'"));
212        assert!(eval_value("a", &vars).unwrap_err().contains("'cel'"));
213    }
214}