Skip to main content

leviath_scripting/
engine.rs

1//! Rhai script engine with Leviath integration.
2
3use crate::{Error, Result};
4use rhai::{Engine, Scope};
5
6/// Sandboxed Rhai engine for executing custom validators, transforms, and logic.
7pub struct ScriptEngine {
8    engine: Engine,
9}
10
11impl ScriptEngine {
12    /// Create a new sandboxed script engine.
13    pub fn new() -> Self {
14        let mut engine = Engine::new();
15        crate::harden(&mut engine, 100_000);
16
17        // Register Leviath functions and types
18        crate::functions::register_functions(&mut engine);
19        crate::types::register_types(&mut engine);
20
21        Self { engine }
22    }
23
24    /// Validate content using a Rhai script.
25    pub fn validate(&self, script: &str, content: &str) -> Result<bool> {
26        let mut scope = Scope::new();
27        scope.push("content", content.to_string());
28
29        self.engine
30            .eval_with_scope::<bool>(&mut scope, script)
31            .map_err(|e| Error::ExecutionFailed(e.to_string()))
32    }
33
34    /// Transform content using a Rhai script.
35    pub fn transform(&self, script: &str, input: rhai::Map) -> Result<String> {
36        let mut scope = Scope::new();
37        scope.push("input", input);
38
39        self.engine
40            .eval_with_scope::<String>(&mut scope, script)
41            .map_err(|e| Error::ExecutionFailed(e.to_string()))
42    }
43
44    /// Execute a generic script with a scope.
45    pub fn execute(&self, script: &str, scope: &mut Scope) -> Result<rhai::Dynamic> {
46        self.engine
47            .eval_with_scope(scope, script)
48            .map_err(|e| Error::ExecutionFailed(e.to_string()))
49    }
50
51    /// Evaluate a taint gate check script.
52    ///
53    /// The script should define a `check(context)` function that returns bool.
54    /// Context map contains: tool, target, taint_level.
55    pub fn check_gate_rule(
56        &self,
57        script: &str,
58        tool: &str,
59        target: Option<&str>,
60        taint_level: &str,
61    ) -> Result<bool> {
62        let mut context = rhai::Map::new();
63        context.insert("tool".into(), rhai::Dynamic::from(tool.to_string()));
64        context.insert(
65            "target".into(),
66            rhai::Dynamic::from(target.unwrap_or("").to_string()),
67        );
68        context.insert(
69            "taint_level".into(),
70            rhai::Dynamic::from(taint_level.to_string()),
71        );
72
73        let mut scope = Scope::new();
74        scope.push("context", context);
75
76        // The script should end with a call to check(context) or
77        // be a simple expression that uses the 'context' variable.
78        self.engine
79            .eval_with_scope::<bool>(&mut scope, script)
80            .map_err(|e| Error::ExecutionFailed(e.to_string()))
81    }
82}
83
84impl Default for ScriptEngine {
85    fn default() -> Self {
86        Self::new()
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    #[test]
95    fn test_engine_creation() {
96        let engine = ScriptEngine::new();
97        // Just verify the engine was created successfully
98        assert!(engine.engine.max_operations() > 0);
99    }
100
101    #[test]
102    fn test_simple_validation() {
103        let engine = ScriptEngine::new();
104        let script = r#"
105            content.contains("test")
106        "#;
107        let result = engine.validate(script, "this is a test");
108        assert!(result.is_ok());
109        assert!(result.unwrap());
110    }
111
112    #[test]
113    fn test_string_operations() {
114        let engine = ScriptEngine::new();
115
116        // Test starts_with
117        let script = r#"content.starts_with("Hello")"#;
118        assert!(engine.validate(script, "Hello, world!").unwrap());
119
120        // Test ends_with
121        let script = r#"content.ends_with("!")"#;
122        assert!(engine.validate(script, "Hello, world!").unwrap());
123
124        // Test trim
125        let script = r#"content.trim() == "hello""#;
126        assert!(engine.validate(script, "  hello  ").unwrap());
127    }
128
129    #[test]
130    fn test_json_validation() {
131        let engine = ScriptEngine::new();
132        let script = r#"is_json(content)"#;
133
134        assert!(engine.validate(script, r#"{"key": "value"}"#).unwrap());
135        assert!(!engine.validate(script, "not json").unwrap());
136    }
137
138    #[test]
139    fn test_mermaid_validation() {
140        let engine = ScriptEngine::new();
141        let script = r#"is_mermaid(content)"#;
142
143        assert!(engine.validate(script, "graph TD\n  A --> B").unwrap());
144        assert!(
145            engine
146                .validate(script, "sequenceDiagram\n  Alice->>Bob: Hello")
147                .unwrap()
148        );
149        assert!(!engine.validate(script, "just text").unwrap());
150    }
151
152    #[test]
153    fn test_token_counting() {
154        let engine = ScriptEngine::new();
155        let script = r#"count_tokens(content) > 10"#;
156
157        let long_text = "a".repeat(50);
158        assert!(engine.validate(script, &long_text).unwrap());
159        assert!(!engine.validate(script, "short").unwrap());
160    }
161
162    #[test]
163    fn test_split_join() {
164        let engine = ScriptEngine::new();
165        let script = r#"
166            let parts = content.split(",");
167            parts.len() == 3 && join(parts, "|") != ""
168        "#;
169        assert!(engine.validate(script, "a,b,c").unwrap());
170    }
171
172    #[test]
173    fn test_sandbox_limits() {
174        let engine = ScriptEngine::new();
175        // Test operation limit by creating an infinite loop
176        let script = r#"
177            let x = 0;
178            loop {
179                x = x + 1;
180                if x > 200000 { break; }
181            }
182            true
183        "#;
184        // Should fail due to operation limit
185        let result = engine.validate(script, "test");
186        assert!(result.is_err());
187    }
188
189    // ─── transform() ────────────────────────────────────────────────────────
190
191    #[test]
192    fn test_transform_success() {
193        let engine = ScriptEngine::new();
194        let mut input = rhai::Map::new();
195        input.insert("name".into(), rhai::Dynamic::from("world".to_string()));
196
197        let script = r#"("hello " + input["name"])"#;
198        let result = engine.transform(script, input);
199        assert_eq!(result.unwrap(), "hello world");
200    }
201
202    #[test]
203    fn test_transform_script_error_returns_execution_failed() {
204        let engine = ScriptEngine::new();
205        let input = rhai::Map::new();
206
207        // Syntax error in the script.
208        let script = "this is not valid rhai {{{";
209        let result = engine.transform(script, input);
210        assert!(result.is_err());
211        assert!(
212            result
213                .unwrap_err()
214                .to_string()
215                .starts_with("Script execution failed:")
216        );
217    }
218
219    #[test]
220    fn test_transform_wrong_return_type_returns_execution_failed() {
221        let engine = ScriptEngine::new();
222        let input = rhai::Map::new();
223
224        // Returns an integer, not a String - eval_with_scope::<String> should error.
225        let script = "42";
226        let result = engine.transform(script, input);
227        assert!(result.is_err());
228    }
229
230    // ─── execute() ──────────────────────────────────────────────────────────
231
232    #[test]
233    fn test_execute_returns_dynamic_value() {
234        let engine = ScriptEngine::new();
235        let mut scope = Scope::new();
236        scope.push("x", 10_i64);
237
238        let result = engine.execute("x * 2", &mut scope);
239        let value = result.unwrap();
240        assert_eq!(value.as_int().unwrap(), 20);
241    }
242
243    #[test]
244    fn test_execute_script_error_returns_execution_failed() {
245        let engine = ScriptEngine::new();
246        let mut scope = Scope::new();
247
248        let result = engine.execute("undefined_function_call()", &mut scope);
249        assert!(result.is_err());
250        assert!(
251            result
252                .unwrap_err()
253                .to_string()
254                .starts_with("Script execution failed:")
255        );
256    }
257
258    #[test]
259    fn test_print_and_debug_statements_invoke_noop_callbacks() {
260        // `print`/`debug` are wired to no-op closures in `new()` to prevent
261        // data leakage from sandboxed scripts; a script that never calls
262        // them leaves those closures registered but never invoked. This
263        // exercises both, proving the sandbox tolerates (and silently
264        // discards) print/debug output instead of erroring.
265        let engine = ScriptEngine::new();
266        let mut scope = Scope::new();
267        let result = engine.execute(r#"print("hello"); debug("world"); true"#, &mut scope);
268        assert!(result.unwrap().as_bool().unwrap());
269    }
270
271    // ─── Default ────────────────────────────────────────────────────────────
272
273    #[test]
274    fn test_default_creates_working_engine() {
275        let engine = ScriptEngine::default();
276        let result = engine.validate("content.len() > 0", "hi");
277        assert!(result.unwrap());
278    }
279
280    // ─── check_gate_rule ────────────────────────────────────────────────────
281
282    #[test]
283    fn test_gate_rule_allows_matching_tool() {
284        let engine = ScriptEngine::new();
285        let script = r#"
286            context["tool"] == "send_email"
287            && context["target"].ends_with("@mycompany.com")
288            && (context["taint_level"] == "public" || context["taint_level"] == "internal")
289        "#;
290        let result = engine
291            .check_gate_rule(
292                script,
293                "send_email",
294                Some("alice@mycompany.com"),
295                "internal",
296            )
297            .unwrap();
298        assert!(result);
299    }
300
301    #[test]
302    fn test_gate_rule_blocks_external_email() {
303        let engine = ScriptEngine::new();
304        let script = r#"
305            context["tool"] == "send_email"
306            && context["target"].ends_with("@mycompany.com")
307        "#;
308        let result = engine
309            .check_gate_rule(script, "send_email", Some("bob@external.com"), "internal")
310            .unwrap();
311        assert!(!result);
312    }
313
314    #[test]
315    fn test_gate_rule_blocks_wrong_tool() {
316        let engine = ScriptEngine::new();
317        let script = r#"context["tool"] == "send_email""#;
318        let result = engine
319            .check_gate_rule(script, "post_to_slack", None, "public")
320            .unwrap();
321        assert!(!result);
322    }
323
324    #[test]
325    fn test_gate_rule_no_target_uses_empty_string() {
326        let engine = ScriptEngine::new();
327        let script = r#"context["target"] == """#;
328        let result = engine
329            .check_gate_rule(script, "shell", None, "public")
330            .unwrap();
331        assert!(result);
332    }
333
334    #[test]
335    fn test_gate_rule_script_error() {
336        let engine = ScriptEngine::new();
337        let result = engine.check_gate_rule("invalid {{ syntax", "shell", None, "public");
338        assert!(result.is_err());
339    }
340}