rs_utcp/plugins/codemode/
mod.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3use std::time::Duration;
4use tokio::sync::RwLock;
5
6use anyhow::{anyhow, Result};
7use rhai::{Dynamic, Engine, EvalAltResult, Map, Scope};
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use tokio::runtime::{Builder, RuntimeFlavor};
11
12use crate::security;
13use crate::tools::{Tool, ToolInputOutputSchema};
14use crate::UtcpClientInterface;
15
16// Security configuration constants
17/// Maximum code snippet size (100KB) to prevent DoS attacks
18const MAX_CODE_SIZE: usize = 100_000;
19
20/// Maximum timeout for code execution (30 seconds)
21const MAX_TIMEOUT_MS: u64 = 45_000;
22
23/// Default timeout if none specified (5 seconds)
24const DEFAULT_TIMEOUT_MS: u64 = 5_000;
25
26/// Maximum size for script output (10MB) to prevent memory exhaustion
27const MAX_OUTPUT_SIZE: usize = 10_000_000;
28
29/// Maximum operations per script execution
30const MAX_OPERATIONS: u64 = 100_000;
31
32/// Maximum expression depth to prevent stack overflow
33const MAX_EXPR_DEPTH: (usize, usize) = (64, 32);
34
35/// Maximum string size (1MB) within scripts
36const MAX_STRING_SIZE: usize = 1_000_000;
37
38/// Maximum array/map sizes to prevent memory exhaustion
39const MAX_ARRAY_SIZE: usize = 10_000;
40const MAX_MAP_SIZE: usize = 10_000;
41
42/// Maximum number of modules
43const MAX_MODULES: usize = 16;
44
45/// Dangerous code patterns that are prohibited
46const DANGEROUS_PATTERNS: &[&str] = &[
47    "eval(",
48    "import ",
49    "fn ",         // Function definitions could be abused
50    "while true", // Infinite loops
51    "loop {",     // Infinite loops
52];
53
54/// Minimal facade exposing UTCP calls to Rhai scripts executed by CodeMode.
55pub struct CodeModeUtcp {
56    client: Arc<dyn UtcpClientInterface>,
57}
58
59impl CodeModeUtcp {
60    /// Wrap an `UtcpClientInterface` so codemode scripts can invoke tools.
61    pub fn new(client: Arc<dyn UtcpClientInterface>) -> Self {
62        Self { client }
63    }
64
65    /// Validates code for security issues before execution.
66    fn validate_code(&self, code: &str) -> Result<()> {
67        // Check code size
68        if code.len() > MAX_CODE_SIZE {
69            return Err(anyhow!(
70                "Code size {} bytes exceeds maximum allowed {} bytes",
71                code.len(),
72                MAX_CODE_SIZE
73            ));
74        }
75
76        // Check for dangerous patterns
77        for pattern in DANGEROUS_PATTERNS {
78            if code.contains(pattern) {
79                return Err(anyhow!(
80                    "Code contains prohibited pattern: '{}'",
81                    pattern
82                ));
83            }
84        }
85
86        Ok(())
87    }
88
89
90
91    /// Execute a snippet or JSON payload, returning the resulting value and captured output.
92    pub async fn execute(&self, args: CodeModeArgs) -> Result<CodeModeResult> {
93        // Validate code before execution
94        self.validate_code(&args.code)?;
95
96        // Determine and validate timeout
97        let timeout_ms = args.timeout.unwrap_or(DEFAULT_TIMEOUT_MS);
98        security::validate_timeout(timeout_ms, MAX_TIMEOUT_MS)?;
99
100        // If it's JSON already, return it directly (no execution needed)
101        if let Ok(json) = serde_json::from_str::<Value>(&args.code) {
102            return Ok(CodeModeResult {
103                value: json,
104                stdout: String::new(),
105                stderr: String::new(),
106            });
107        }
108
109        // Execute with timeout
110        let result = tokio::time::timeout(
111            Duration::from_millis(timeout_ms),
112            self.eval_rusty_snippet(&args.code, Some(timeout_ms)),
113        )
114        .await;
115
116        let value = match result {
117            Ok(Ok(v)) => v,
118            Ok(Err(e)) => return Err(e),
119            Err(_) => {
120                return Err(anyhow!("Code execution timed out after {}ms", timeout_ms));
121            }
122        };
123
124        // Validate output size to prevent memory exhaustion
125        let serialized = serde_json::to_vec(&value)?;
126        if serialized.len() > MAX_OUTPUT_SIZE {
127            return Err(anyhow!(
128                "Output size {} bytes exceeds maximum allowed {} bytes",
129                serialized.len(),
130                MAX_OUTPUT_SIZE
131            ));
132        }
133
134        Ok(CodeModeResult {
135            value,
136            stdout: String::new(),
137            stderr: String::new(),
138        })
139    }
140
141    fn tool_schema(&self) -> Tool {
142        Tool {
143            name: "codemode.run_code".to_string(),
144            description: "Execute a Rust-like snippet with access to UTCP tools.".to_string(),
145            inputs: ToolInputOutputSchema {
146                type_: "object".to_string(),
147                properties: Some(HashMap::from([
148                    (
149                        "code".to_string(),
150                        serde_json::json!({"type": "string", "description": "Rust-like snippet"}),
151                    ),
152                    (
153                        "timeout".to_string(),
154                        serde_json::json!({"type": "integer", "description": "Timeout ms"}),
155                    ),
156                ])),
157                required: Some(vec!["code".to_string()]),
158                description: None,
159                title: Some("CodeModeArgs".to_string()),
160                items: None,
161                enum_: None,
162                minimum: None,
163                maximum: None,
164                format: None,
165            },
166            outputs: ToolInputOutputSchema {
167                type_: "object".to_string(),
168                properties: Some(HashMap::from([
169                    ("value".to_string(), serde_json::json!({"type": "string"})),
170                    ("stdout".to_string(), serde_json::json!({"type": "string"})),
171                    ("stderr".to_string(), serde_json::json!({"type": "string"})),
172                ])),
173                required: None,
174                description: None,
175                title: Some("CodeModeResult".to_string()),
176                items: None,
177                enum_: None,
178                minimum: None,
179                maximum: None,
180                format: None,
181            },
182            tags: vec!["codemode".to_string(), "utcp".to_string()],
183            average_response_size: None,
184            provider: None,
185        }
186    }
187
188    fn build_engine(&self) -> Engine {
189        let mut engine = Engine::new();
190        
191        // Security: Comprehensive sandboxing using centralized constants
192        engine.set_max_expr_depths(MAX_EXPR_DEPTH.0, MAX_EXPR_DEPTH.1); 
193        engine.set_max_operations(MAX_OPERATIONS); 
194        engine.set_max_modules(MAX_MODULES); 
195        engine.set_max_string_size(MAX_STRING_SIZE); 
196        engine.set_max_array_size(MAX_ARRAY_SIZE); 
197        engine.set_max_map_size(MAX_MAP_SIZE); 
198        
199        // Note: File I/O and other dangerous operations are disabled by default in Rhai
200        // when not explicitly importing the std modules
201        
202        engine.register_fn("sprintf", sprintf);
203
204        let client = self.client.clone();
205        engine.register_fn(
206            "call_tool",
207            move |name: &str, map: Map| -> Result<Dynamic, Box<EvalAltResult>> {
208                // Security: Validate tool name format
209                if name.is_empty() || name.len() > 200 {
210                    return Err(EvalAltResult::ErrorRuntime(
211                        "Invalid tool name length".into(),
212                        rhai::Position::NONE,
213                    )
214                    .into());
215                }
216
217                let args_val = serde_json::to_value(map).map_err(|e| {
218                    EvalAltResult::ErrorRuntime(e.to_string().into(), rhai::Position::NONE)
219                })?;
220                let args = value_to_map(args_val)?;
221
222                let res = block_on_any_runtime(async { client.call_tool(name, args).await })
223                    .map_err(|e| {
224                        EvalAltResult::ErrorRuntime(e.to_string().into(), rhai::Position::NONE)
225                    })?;
226
227                Ok(rhai::serde::to_dynamic(res).map_err(|e| {
228                    EvalAltResult::ErrorRuntime(e.to_string().into(), rhai::Position::NONE)
229                })?)
230            },
231        );
232
233        let client = self.client.clone();
234        engine.register_fn(
235            "call_tool_stream",
236            move |name: &str, map: Map| -> Result<Dynamic, Box<EvalAltResult>> {
237                // Security: Validate tool name format
238                if name.is_empty() || name.len() > 200 {
239                    return Err(EvalAltResult::ErrorRuntime(
240                        "Invalid tool name length".into(),
241                        rhai::Position::NONE,
242                    )
243                    .into());
244                }
245
246                let args_val = serde_json::to_value(map).map_err(|e| {
247                    EvalAltResult::ErrorRuntime(e.to_string().into(), rhai::Position::NONE)
248                })?;
249                let args = value_to_map(args_val)?;
250
251                let mut stream =
252                    block_on_any_runtime(async { client.call_tool_stream(name, args).await })
253                        .map_err(|e| {
254                            EvalAltResult::ErrorRuntime(e.to_string().into(), rhai::Position::NONE)
255                        })?;
256
257                let mut items = Vec::new();
258                // Security: Limit maximum number of stream items to prevent memory exhaustion
259                const MAX_STREAM_ITEMS: usize = 10_000;
260                
261                loop {
262                    if items.len() >= MAX_STREAM_ITEMS {
263                        return Err(EvalAltResult::ErrorRuntime(
264                            format!("Stream exceeded maximum {} items", MAX_STREAM_ITEMS).into(),
265                            rhai::Position::NONE,
266                        )
267                        .into());
268                    }
269
270                    let next =
271                        block_on_any_runtime(async { stream.next().await }).map_err(|e| {
272                            EvalAltResult::ErrorRuntime(e.to_string().into(), rhai::Position::NONE)
273                        })?;
274                    match next {
275                        Some(value) => items.push(value),
276                        None => break,
277                    }
278                }
279
280                if let Err(e) = block_on_any_runtime(async { stream.close().await }) {
281                    return Err(EvalAltResult::ErrorRuntime(
282                        e.to_string().into(),
283                        rhai::Position::NONE,
284                    )
285                    .into());
286                }
287
288                Ok(rhai::serde::to_dynamic(items).map_err(|e| {
289                    EvalAltResult::ErrorRuntime(e.to_string().into(), rhai::Position::NONE)
290                })?)
291            },
292        );
293
294        let client = self.client.clone();
295        engine.register_fn(
296            "search_tools",
297            move |query: &str, limit: i64| -> Result<Dynamic, Box<EvalAltResult>> {
298                // Security: Validate query length
299                if query.len() > 1000 {
300                    return Err(EvalAltResult::ErrorRuntime(
301                        "Search query too long (max 1000 chars)".into(),
302                        rhai::Position::NONE,
303                    )
304                    .into());
305                }
306
307                // Security: Enforce reasonable search limit
308                const MAX_SEARCH_LIMIT: i64 = 500;
309                let safe_limit = if limit <= 0 || limit > MAX_SEARCH_LIMIT {
310                    MAX_SEARCH_LIMIT
311                } else {
312                    limit
313                };
314
315                let res = block_on_any_runtime(async {
316                    client.search_tools(query, safe_limit as usize).await
317                })
318                .map_err(|e| {
319                    EvalAltResult::ErrorRuntime(e.to_string().into(), rhai::Position::NONE)
320                })?;
321                Ok(rhai::serde::to_dynamic(res).map_err(|e| {
322                    EvalAltResult::ErrorRuntime(e.to_string().into(), rhai::Position::NONE)
323                })?)
324            },
325        );
326
327        engine
328    }
329
330    async fn eval_rusty_snippet(&self, code: &str, _timeout_ms: Option<u64>) -> Result<Value> {
331        let wrapped = format!("let __out = {{ {} }};\n__out", code);
332        let engine = self.build_engine();
333        let mut scope = Scope::new();
334
335        let dyn_result = engine.eval_with_scope::<Dynamic>(&mut scope, &wrapped);
336        let dyn_value = dyn_result.map_err(|e| anyhow!("codemode eval error: {}", e))?;
337        let value: Value = rhai::serde::from_dynamic(&dyn_value)
338            .map_err(|e| anyhow!("Failed to convert result: {}", e))?;
339        Ok(value)
340    }
341
342    /// Expose the codemode tool definition for registration.
343    pub fn tool(&self) -> Tool {
344        self.tool_schema()
345    }
346
347    /// Convenience helpers mirroring go-utcp codemode helper exports.
348    pub async fn call_tool(&self, name: &str, args: HashMap<String, Value>) -> Result<Value> {
349        self.client.call_tool(name, args).await
350    }
351
352    pub async fn call_tool_stream(
353        &self,
354        name: &str,
355        args: HashMap<String, Value>,
356    ) -> Result<Box<dyn crate::transports::stream::StreamResult>> {
357        self.client.call_tool_stream(name, args).await
358    }
359
360    pub async fn search_tools(&self, query: &str, limit: usize) -> Result<Vec<Tool>> {
361        self.client.search_tools(query, limit).await
362    }
363}
364
365#[async_trait::async_trait]
366pub trait LlmModel: Send + Sync {
367    /// Produce a completion for the provided prompt.
368    async fn complete(&self, prompt: &str) -> Result<Value>;
369}
370
371/// High-level orchestrator that mirrors go-utcp's CodeMode flow:
372/// 1) Decide if tools are needed
373/// 2) Select tools by name
374/// 3) Ask the model to emit a Rhai snippet using call_tool helpers
375/// 4) Execute the snippet via CodeMode
376pub struct CodemodeOrchestrator {
377    codemode: Arc<CodeModeUtcp>,
378    model: Arc<dyn LlmModel>,
379    tool_specs_cache: RwLock<Option<String>>,
380}
381
382impl CodemodeOrchestrator {
383    /// Create a new orchestrator backed by a CodeMode UTCP shim and an LLM model.
384    pub fn new(codemode: Arc<CodeModeUtcp>, model: Arc<dyn LlmModel>) -> Self {
385        Self {
386            codemode,
387            model,
388            tool_specs_cache: RwLock::new(None),
389        }
390    }
391
392    /// Run the full orchestration flow. Returns Ok(None) if the model says no tools are needed
393    /// or fails to pick any tools. Otherwise returns the codemode execution result.
394    pub async fn call_prompt(&self, prompt: &str) -> Result<Option<Value>> {
395        let specs = self.render_tool_specs().await?;
396
397        if !self.decide_if_tools_needed(prompt, &specs).await? {
398            return Ok(None);
399        }
400
401        let selected = self.select_tools(prompt, &specs).await?;
402        if selected.is_empty() {
403            return Ok(None);
404        }
405
406        let snippet = self.generate_snippet(prompt, &selected, &specs).await?;
407        let raw = self
408            .codemode
409            .execute(CodeModeArgs {
410                code: snippet,
411                timeout: Some(20_000),
412            })
413            .await?;
414
415        Ok(Some(raw.value))
416    }
417
418    async fn render_tool_specs(&self) -> Result<String> {
419        {
420            let cache = self.tool_specs_cache.read().await;
421            if let Some(specs) = &*cache {
422                return Ok(specs.clone());
423            }
424        }
425
426        let tools = self
427            .codemode
428            .search_tools("", 200)
429            .await
430            .unwrap_or_default();
431        let mut rendered =
432            String::from("UTCP TOOL REFERENCE (use exact field names and required keys):\n");
433        for tool in tools {
434            rendered.push_str(&format!("TOOL: {} - {}\n", tool.name, tool.description));
435
436            rendered.push_str("INPUTS:\n");
437            match tool.inputs.properties.as_ref() {
438                Some(props) if !props.is_empty() => {
439                    for (key, schema) in props {
440                        rendered.push_str(&format!("  - {}: {}\n", key, schema_type_hint(schema)));
441                    }
442                }
443                _ => rendered.push_str("  - none\n"),
444            }
445
446            if let Some(required) = tool.inputs.required.as_ref() {
447                if !required.is_empty() {
448                    rendered.push_str("  REQUIRED:\n");
449                    for field in required {
450                        rendered.push_str(&format!("  - {}\n", field));
451                    }
452                }
453            }
454
455            rendered.push_str("OUTPUTS:\n");
456            match tool.outputs.properties.as_ref() {
457                Some(props) if !props.is_empty() => {
458                    for (key, schema) in props {
459                        rendered.push_str(&format!("  - {}: {}\n", key, schema_type_hint(schema)));
460                    }
461                }
462                _ => {
463                    if !tool.outputs.type_.is_empty() {
464                        rendered.push_str(&format!("  - type: {}\n", tool.outputs.type_));
465                    } else {
466                        rendered.push_str("  - (shape unspecified)\n");
467                    }
468                }
469            }
470
471            rendered.push('\n');
472        }
473
474        let mut cache = self.tool_specs_cache.write().await;
475        *cache = Some(rendered.clone());
476        Ok(rendered)
477    }
478
479    async fn decide_if_tools_needed(&self, prompt: &str, specs: &str) -> Result<bool> {
480        let request = format!(
481            "You can call tools described below. Respond with only 'yes' or 'no'.\n\nTOOLS:\n{}\n\nUSER:\n{}",
482            specs, prompt
483        );
484        let resp_val = self.model.complete(&request).await?;
485        Ok(resp_val
486            .as_str()
487            .unwrap_or_default()
488            .trim_start()
489            .to_ascii_lowercase()
490            .starts_with('y'))
491    }
492
493    async fn select_tools(&self, prompt: &str, specs: &str) -> Result<Vec<String>> {
494        let request = format!(
495            "Choose relevant tool names from the list. Respond with a comma-separated list of names only.\n\nTOOLS:\n{}\n\nUSER:\n{}",
496            specs, prompt
497        );
498        let resp_val = self.model.complete(&request).await?;
499        let resp = resp_val.as_str().unwrap_or_default();
500        let mut out = Vec::new();
501        for name in resp.split(',') {
502            let n = name.trim();
503            if !n.is_empty() {
504                out.push(n.to_string());
505            }
506        }
507        Ok(out)
508    }
509
510    async fn generate_snippet(
511        &self,
512        prompt: &str,
513        tools: &[String],
514        specs: &str,
515    ) -> Result<String> {
516        let tool_list = tools.join(", ");
517        let request = format!(
518            "Generate a Rhai snippet that chains UTCP tool calls to satisfy the user request.\n\
519Use ONLY these tools: {tool_list}.\n\
520Helpers available: call_tool(name, map), call_tool_stream(name, map) -> array of streamed chunks, search_tools(query, limit), sprintf(fmt, list).\n\
521Use Rhai map syntax #{{\"field\": value}} with exact input field names; include required fields and never invent new keys.\n\
522You may call multiple tools, store results in variables, and pass them into subsequent tools.\n\
523When using call_tool_stream, treat the returned array as the streamed items and chain it into later calls or the final output.\n\
524Return the final value as the last expression (map/list/scalar). No markdown or commentary, code only.\n\
525\nUSER:\n{prompt}\n\nTOOLS (use exact field names):\n{specs}"
526        );
527        let resp_val = self.model.complete(&request).await?;
528        Ok(resp_val.as_str().unwrap_or_default().trim().to_string())
529    }
530}
531
532#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
533/// Arguments accepted by the codemode tool.
534pub struct CodeModeArgs {
535    pub code: String,
536    #[serde(default)]
537    pub timeout: Option<u64>,
538}
539
540#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
541/// Result payload returned from codemode execution.
542pub struct CodeModeResult {
543    pub value: Value,
544    #[serde(default)]
545    pub stdout: String,
546    #[serde(default)]
547    pub stderr: String,
548}
549
550fn schema_type_hint(value: &Value) -> String {
551    if let Some(t) = value.get("type").and_then(|v| v.as_str()) {
552        t.to_string()
553    } else if let Some(s) = value.as_str() {
554        s.to_string()
555    } else if value.is_array() {
556        "array".to_string()
557    } else if value.is_object() {
558        "object".to_string()
559    } else {
560        "any".to_string()
561    }
562}
563
564fn value_to_map(value: Value) -> Result<HashMap<String, Value>, Box<EvalAltResult>> {
565    match value {
566        Value::Object(obj) => Ok(obj.into_iter().collect()),
567        _ => Err(EvalAltResult::ErrorRuntime(
568            "call_tool expects object args".into(),
569            rhai::Position::NONE,
570        )
571        .into()),
572    }
573}
574
575/// Minimal string formatter exposed to Rhai snippets.
576/// Security: Limited to prevent DoS attacks.
577pub fn sprintf(fmt: &str, args: &[Dynamic]) -> String {
578    // Security: Limit format string size
579    const MAX_FMT_SIZE: usize = 10_000;
580    const MAX_ARGS: usize = 100;
581
582    if fmt.len() > MAX_FMT_SIZE {
583        return "[ERROR: Format string too long]".to_string();
584    }
585
586    if args.len() > MAX_ARGS {
587        return "[ERROR: Too many arguments]".to_string();
588    }
589
590    let mut out = fmt.to_string();
591    for rendered in args.iter().map(|v| v.to_string()) {
592        // Security: Limit argument string length
593        let safe_rendered = if rendered.len() > 1000 {
594            format!("{}...[truncated]", &rendered[..1000])
595        } else {
596            rendered
597        };
598        out = out.replacen("{}", &safe_rendered, 1);
599    }
600    
601    // Security: Limit total output size
602    if out.len() > MAX_FMT_SIZE * 2 {
603        out.truncate(MAX_FMT_SIZE * 2);
604        out.push_str("...[truncated]");
605    }
606    
607    out
608}
609
610fn block_on_any_runtime<F, T>(fut: F) -> Result<T, anyhow::Error>
611where
612    F: std::future::Future<Output = Result<T, anyhow::Error>>,
613    T: Send + 'static,
614{
615    match tokio::runtime::Handle::try_current() {
616        Ok(handle) => match handle.runtime_flavor() {
617            RuntimeFlavor::MultiThread => tokio::task::block_in_place(|| handle.block_on(fut)),
618            RuntimeFlavor::CurrentThread => {
619                let rt = Builder::new_current_thread().enable_all().build()?;
620                rt.block_on(fut)
621            }
622            _ => {
623                let rt = Builder::new_current_thread().enable_all().build()?;
624                rt.block_on(fut)
625            }
626        },
627        Err(_) => {
628            let rt = Builder::new_current_thread().enable_all().build()?;
629            rt.block_on(fut)
630        }
631    }
632}
633
634#[cfg(test)]
635mod tests {
636    use super::*;
637    use crate::tools::Tool;
638    use crate::transports::stream::boxed_vec_stream;
639    use tokio::sync::Mutex;
640
641    #[derive(Clone)]
642    struct MockClient {
643        called: Arc<Mutex<Vec<String>>>,
644    }
645
646    #[async_trait::async_trait]
647    impl UtcpClientInterface for MockClient {
648        async fn register_tool_provider(
649            &self,
650            _prov: Arc<dyn crate::providers::base::Provider>,
651        ) -> Result<Vec<Tool>> {
652            Ok(vec![])
653        }
654
655        async fn register_tool_provider_with_tools(
656            &self,
657            _prov: Arc<dyn crate::providers::base::Provider>,
658            tools: Vec<Tool>,
659        ) -> Result<Vec<Tool>> {
660            Ok(tools)
661        }
662
663        async fn deregister_tool_provider(&self, _provider_name: &str) -> Result<()> {
664            Ok(())
665        }
666
667        async fn call_tool(&self, tool_name: &str, _args: HashMap<String, Value>) -> Result<Value> {
668            self.called.lock().await.push(tool_name.to_string());
669            Ok(Value::Number(serde_json::Number::from(5)))
670        }
671
672        async fn search_tools(&self, query: &str, _limit: usize) -> Result<Vec<Tool>> {
673            self.called.lock().await.push(format!("search:{query}"));
674            Ok(vec![])
675        }
676
677        fn get_transports(&self) -> HashMap<String, Arc<dyn crate::transports::ClientTransport>> {
678            HashMap::new()
679        }
680
681        async fn call_tool_stream(
682            &self,
683            tool_name: &str,
684            _args: HashMap<String, Value>,
685        ) -> Result<Box<dyn crate::transports::stream::StreamResult>> {
686            self.called.lock().await.push(format!("stream:{tool_name}"));
687            Ok(boxed_vec_stream(vec![Value::String("chunk".into())]))
688        }
689    }
690
691    #[tokio::test(flavor = "multi_thread")]
692    async fn codemode_helpers_forward_to_client() {
693        let client = Arc::new(MockClient {
694            called: Arc::new(Mutex::new(Vec::new())),
695        });
696        let codemode = CodeModeUtcp::new(client.clone());
697
698        codemode
699            .call_tool("demo.tool", HashMap::new())
700            .await
701            .unwrap();
702        codemode.search_tools("demo", 5).await.unwrap();
703        let mut stream = codemode
704            .call_tool_stream("demo.tool", HashMap::new())
705            .await
706            .unwrap();
707        let _ = stream.next().await.unwrap();
708
709        let calls = client.called.lock().await.clone();
710        assert_eq!(calls, vec!["demo.tool", "search:demo", "stream:demo.tool"]);
711    }
712
713    #[tokio::test(flavor = "multi_thread")]
714    async fn execute_runs_rusty_snippet_and_call_tool() {
715        let client = Arc::new(MockClient {
716            called: Arc::new(Mutex::new(Vec::new())),
717        });
718        let codemode = CodeModeUtcp::new(client);
719
720        let code = r#"let x = 2 + 3; let y = call_tool("math.add", #{"a":1}); x + y"#;
721        let args = CodeModeArgs {
722            code: code.into(),
723            timeout: Some(1000),
724        };
725        let res = codemode.execute(args).await.unwrap();
726        assert_eq!(res.value, serde_json::json!(10));
727    }
728
729    #[tokio::test(flavor = "multi_thread")]
730    async fn execute_collects_stream_results() {
731        let client = Arc::new(MockClient {
732            called: Arc::new(Mutex::new(Vec::new())),
733        });
734        let codemode = CodeModeUtcp::new(client.clone());
735
736        let code = r#"let chunks = call_tool_stream("demo.tool", #{}); chunks"#;
737        let args = CodeModeArgs {
738            code: code.into(),
739            timeout: Some(1_000),
740        };
741        let res = codemode.execute(args).await.unwrap();
742        assert_eq!(res.value, serde_json::json!(["chunk"]));
743        let calls = client.called.lock().await.clone();
744        assert_eq!(calls, vec!["stream:demo.tool"]);
745    }
746
747    // Security Tests
748
749    #[tokio::test(flavor = "multi_thread")]
750    async fn security_rejects_oversized_code() {
751        let client = Arc::new(MockClient {
752            called: Arc::new(Mutex::new(Vec::new())),
753        });
754        let codemode = CodeModeUtcp::new(client);
755
756        // Create code larger than MAX_CODE_SIZE (100KB)
757        let large_code = "x".repeat(150_000);
758        let args = CodeModeArgs {
759            code: large_code,
760            timeout: Some(1000),
761        };
762
763        let result = codemode.execute(args).await;
764        assert!(result.is_err());
765        assert!(result.unwrap_err().to_string().contains("exceeds maximum"));
766    }
767
768    #[tokio::test(flavor = "multi_thread")]
769    async fn security_rejects_dangerous_patterns() {
770        let client = Arc::new(MockClient {
771            called: Arc::new(Mutex::new(Vec::new())),
772        });
773        let codemode = CodeModeUtcp::new(client);
774
775        // Test each dangerous pattern
776        let dangerous_codes = vec![
777            "eval(some_code)",
778            "import some_module",
779            "fn evil() { }",
780            "while true { }",
781            "loop { break; }",
782        ];
783
784        for code in dangerous_codes {
785            let args = CodeModeArgs {
786                code: code.to_string(),
787                timeout: Some(1000),
788            };
789
790            let result = codemode.execute(args).await;
791            assert!(result.is_err(), "Should reject: {}", code);
792            assert!(result.unwrap_err().to_string().contains("prohibited pattern"));
793        }
794    }
795
796    #[tokio::test(flavor = "multi_thread")]
797    async fn security_enforces_timeout() {
798        let client = Arc::new(MockClient {
799            called: Arc::new(Mutex::new(Vec::new())),
800        });
801        let codemode = CodeModeUtcp::new(client);
802
803        // Code that takes a while (but not infinite due to operation limits)
804        let code = r#"let sum = 0; for i in 0..100000 { sum = sum + i; } sum"#;
805        let args = CodeModeArgs {
806            code: code.to_string(),
807            timeout: Some(1), // Very short timeout - 1ms
808        };
809
810        let result = codemode.execute(args).await;
811        // This should timeout or complete very fast
812        // Either way, we're testing that timeout mechanism works
813        if result.is_err() {
814            let err = result.unwrap_err().to_string();
815            // It might timeout or hit operation limit
816            assert!(
817                err.contains("timeout") || err.contains("operations"),
818                "Unexpected error: {}",
819                err
820            );
821        }
822    }
823
824    #[tokio::test(flavor = "multi_thread")]
825    async fn security_rejects_excessive_timeout() {
826        let client = Arc::new(MockClient {
827            called: Arc::new(Mutex::new(Vec::new())),
828        });
829        let codemode = CodeModeUtcp::new(client);
830
831        let args = CodeModeArgs {
832            code: "42".to_string(),
833            timeout: Some(60_000), // 60 seconds - over MAX_TIMEOUT_MS
834        };
835
836        let result = codemode.execute(args).await;
837        assert!(result.is_err());
838        assert!(result.unwrap_err().to_string().contains("exceeds maximum"));
839    }
840
841    #[tokio::test(flavor = "multi_thread")]
842    async fn security_limits_output_size() {
843        let client = Arc::new(MockClient {
844            called: Arc::new(Mutex::new(Vec::new())),
845        });
846        let codemode = CodeModeUtcp::new(client);
847
848        // Create code that would produce large output through array limits
849        // This will hit the array size limit (10,000 items)
850        let code = r#"let arr = []; for i in 0..15000 { arr.push(i); } arr"#;
851        let args = CodeModeArgs {
852            code: code.to_string(),
853            timeout: Some(10_000),
854        };
855
856        let result = codemode.execute(args).await;
857        // Should fail due to array size limit or operations limit
858        assert!(result.is_err(), "Should fail due to limits");
859        let err = result.unwrap_err().to_string();
860        assert!(
861            err.contains("array") || err.contains("operations") || err.contains("eval error"),
862            "Unexpected error: {}",
863            err
864        );
865    }
866
867    #[test]
868    fn security_sprintf_limits_format_size() {
869        let fmt = "x".repeat(20_000); // Over MAX_FMT_SIZE
870        let result = sprintf(&fmt, &[]);
871        assert_eq!(result, "[ERROR: Format string too long]");
872    }
873
874    #[test]
875    fn security_sprintf_limits_args_count() {
876        let args: Vec<Dynamic> = (0..200).map(|i| Dynamic::from(i)).collect();
877        let result = sprintf("{}", &args);
878        assert_eq!(result, "[ERROR: Too many arguments]");
879    }
880
881    #[test]
882    fn security_sprintf_truncates_long_args() {
883        let long_arg = Dynamic::from("x".repeat(2000));
884        let result = sprintf("Value: {}", &[long_arg]);
885        assert!(result.contains("...[truncated]"));
886    }
887
888    #[test]
889    fn security_sprintf_limits_output_size() {
890        let fmt = "{}".repeat(10_000);
891        let args: Vec<Dynamic> = (0..10_000).map(|i| Dynamic::from(format!("arg{}", i))).collect();
892        let result = sprintf(&fmt, &args[..100]); // Use fewer args to stay under MAX_ARGS
893        // Output should be truncated if it gets too large
894        if result.len() > 20_000 {
895            assert!(result.contains("...[truncated]"));
896        }
897    }
898}