Skip to main content

lean_ctx/tools/
ctx_execute.rs

1use crate::core::sandbox::{self, SandboxResult};
2use crate::core::tokens::count_tokens;
3use crate::server::tool_trait::ShellOutcome;
4
5/// Executes a code snippet in a sandboxed environment.
6/// Returns the formatted output plus the structured outcome so the MCP layer
7/// can set `isError`/`structuredContent` on failures (GitHub #389).
8pub fn handle(
9    language: &str,
10    code: &str,
11    intent: Option<&str>,
12    timeout: Option<u64>,
13) -> (String, ShellOutcome) {
14    if language.eq_ignore_ascii_case("shell")
15        && let Err(message) = crate::core::shell_allowlist::check_shell_allowlist(code)
16    {
17        return (message.to_string(), ShellOutcome::Blocked);
18    }
19    let result = sandbox::execute(language, code, timeout);
20    (
21        format_result(&result, intent),
22        ShellOutcome::Exit(result.exit_code),
23    )
24}
25
26/// Reads a file from disk, detects its language, and executes a processing script.
27///
28/// `project_root` is used for pathjail validation. If `None`, the current
29/// directory is used as the jail root. Precondition failures (path rejected,
30/// unreadable, too large) never execute anything and report `Blocked`.
31pub fn handle_file(
32    path: &str,
33    intent: Option<&str>,
34    project_root: Option<&str>,
35) -> (String, ShellOutcome) {
36    let jail_root = match project_root {
37        Some(r) => std::path::PathBuf::from(r),
38        None => std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
39    };
40    let candidate = std::path::Path::new(path);
41    let jailed = match crate::core::pathjail::jail_path(candidate, &jail_root) {
42        Ok(p) => p,
43        Err(e) => return (format!("Path rejected: {e}"), ShellOutcome::Blocked),
44    };
45    let path_str = jailed.to_string_lossy();
46
47    let cap = crate::core::limits::max_read_bytes();
48    let meta = match std::fs::metadata(&*jailed) {
49        Ok(m) => m,
50        Err(e) => {
51            return (
52                format!("Error reading {path_str}: {e}"),
53                ShellOutcome::Blocked,
54            );
55        }
56    };
57    if meta.len() > cap as u64 {
58        return (
59            format!(
60                "File too large ({} bytes, limit {cap} bytes). Use a line-range read instead.",
61                meta.len()
62            ),
63            ShellOutcome::Blocked,
64        );
65    }
66    let content = match std::fs::read_to_string(&*jailed) {
67        Ok(c) => c,
68        Err(e) => {
69            return (
70                format!("Error reading {path_str}: {e}"),
71                ShellOutcome::Blocked,
72            );
73        }
74    };
75
76    let language = detect_language_from_extension(path);
77    let code = build_file_processing_script(&language, &content, intent);
78    let result = sandbox::execute(&language, &code, None);
79    (
80        format_result(&result, intent),
81        ShellOutcome::Exit(result.exit_code),
82    )
83}
84
85/// Executes multiple (language, code) pairs in parallel and returns aggregated
86/// results. The outcome carries the first non-zero exit code (0 when all
87/// tasks succeeded), so one failing task marks the whole batch as failed.
88pub fn handle_batch(items: &[(String, String)]) -> (String, ShellOutcome) {
89    for (index, (language, code)) in items.iter().enumerate() {
90        if language.eq_ignore_ascii_case("shell")
91            && let Err(message) = crate::core::shell_allowlist::check_shell_allowlist(code)
92        {
93            return (
94                format!(
95                    "batch item {} blocked by shell policy:\n{message}",
96                    index + 1
97                ),
98                ShellOutcome::Blocked,
99            );
100        }
101    }
102    let results = sandbox::batch_execute(items);
103    let mut output = Vec::new();
104
105    for (i, result) in results.iter().enumerate() {
106        let label = format!("[{}/{}] {}", i + 1, results.len(), result.language);
107        if result.exit_code == 0 {
108            let stdout = result.stdout.trim();
109            if stdout.is_empty() {
110                output.push(format!("{label}: (no output) [{} ms]", result.duration_ms));
111            } else {
112                output.push(format!("{label}: {stdout} [{} ms]", result.duration_ms));
113            }
114        } else {
115            let stderr = result.stderr.trim();
116            output.push(format!(
117                "{label}: EXIT {} — {stderr} [{} ms]",
118                result.exit_code, result.duration_ms
119            ));
120        }
121    }
122
123    let total_ms: u64 = results.iter().map(|r| r.duration_ms).sum();
124    output.push(format!("\n{} tasks, {} ms total", results.len(), total_ms));
125    let first_failure = results
126        .iter()
127        .map(|r| r.exit_code)
128        .find(|c| *c != 0)
129        .unwrap_or(0);
130    (output.join("\n"), ShellOutcome::Exit(first_failure))
131}
132
133fn format_result(result: &SandboxResult, intent: Option<&str>) -> String {
134    let mut parts = Vec::new();
135
136    if result.exit_code == 0 {
137        let stdout = result.stdout.trim();
138        if stdout.is_empty() {
139            parts.push("(no output)".to_string());
140        } else {
141            let raw_tokens = count_tokens(stdout);
142            parts.push(stdout.to_string());
143
144            if let Some(intent_desc) = intent
145                && raw_tokens > 50
146            {
147                parts.push(format!("[intent: {intent_desc}]"));
148            }
149        }
150    } else {
151        if !result.stdout.is_empty() {
152            parts.push(result.stdout.trim().to_string());
153        }
154        parts.push(format!(
155            "EXIT {} — {}",
156            result.exit_code,
157            result.stderr.trim()
158        ));
159    }
160
161    parts.push(format!("[{} | {} ms]", result.language, result.duration_ms));
162    parts.join("\n")
163}
164
165fn detect_language_from_extension(path: &str) -> String {
166    let ext = path.rsplit('.').next().unwrap_or("");
167    match ext {
168        "js" | "mjs" | "cjs" => "javascript",
169        "ts" | "mts" | "cts" => "typescript",
170        "py" | "json" | "csv" | "log" | "txt" | "xml" | "yaml" | "yml" | "md" | "html" => "python",
171        "rb" => "ruby",
172        "go" => "go",
173        "rs" => "rust",
174        "php" => "php",
175        "pl" => "perl",
176        "r" | "R" => "r",
177        "ex" | "exs" => "elixir",
178        _ => "shell",
179    }
180    .to_string()
181}
182
183fn sanitize_intent(raw: &str) -> String {
184    raw.chars()
185        .filter(|c| c.is_alphanumeric() || *c == ' ' || *c == '-' || *c == '_' || *c == '.')
186        .take(200)
187        .collect()
188}
189
190/// Escape a path for safe embedding inside a Python raw double-quoted string.
191/// Handles embedded double-quotes which would break `r"..."`.
192fn escape_for_python_raw(path: &str) -> String {
193    path.replace('"', r#"\" + '"' + r""#)
194}
195
196/// Escape a path for safe embedding inside a shell double-quoted string.
197/// Handles `$`, backtick, `\`, and `"` which are special inside double quotes.
198fn escape_for_shell_dq(path: &str) -> String {
199    let mut out = String::with_capacity(path.len());
200    for ch in path.chars() {
201        match ch {
202            '$' | '`' | '"' | '\\' => {
203                out.push('\\');
204                out.push(ch);
205            }
206            _ => out.push(ch),
207        }
208    }
209    out
210}
211
212fn build_file_processing_script(language: &str, content: &str, intent: Option<&str>) -> String {
213    let Ok(tmp) = tempfile::Builder::new()
214        .prefix("lean-ctx-exec-")
215        .suffix(".dat")
216        .tempfile()
217    else {
218        return "echo 'lean-ctx: failed to create temp file'".to_string();
219    };
220    let _ = std::fs::write(tmp.path(), content);
221    let tmp_path = tmp.path().to_string_lossy().to_string();
222    let _keep = tmp.into_temp_path();
223    let intent_str = sanitize_intent(intent.unwrap_or("summarize the content"));
224
225    if language == "python" {
226        let py_path = escape_for_python_raw(&tmp_path);
227        format!(
228            r#"
229    import os
230
231    with open(r"{py_path}", "r", encoding="utf-8") as f:
232        data = f.read()
233    os.remove(r"{py_path}")
234
235    lines = data.strip().split('\n')
236    total_lines = len(lines)
237    total_bytes = len(data.encode('utf-8'))
238
239    word_count = sum(len(line.split()) for line in lines)
240
241    print(f"{{total_lines}} lines, {{total_bytes}} bytes, {{word_count}} words")
242    print("Intent: {intent_str}")
243
244    if total_lines > 10:
245        print(f"First 3: {{lines[:3]}}")
246        print(f"Last 3: {{lines[-3:]}}")
247    "#
248        )
249    } else {
250        let sh_path = escape_for_shell_dq(&tmp_path);
251        format!(
252            r#"
253    data=$(cat "{sh_path}")
254    rm -f "{sh_path}"
255    lines=$(echo "$data" | wc -l | tr -d ' ')
256    bytes=$(echo "$data" | wc -c | tr -d ' ')
257    echo "$lines lines, $bytes bytes"
258    echo 'Intent: {intent_str}'
259    echo "$data" | head -3
260    echo "..."
261    echo "$data" | tail -3
262    "#
263        )
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn handle_simple_python() {
273        let (result, outcome) = handle("python", "print(2 + 2)", None, None);
274        assert!(result.contains('4'));
275        assert!(result.contains("python"));
276        assert_eq!(outcome, ShellOutcome::Exit(0), "success must report exit 0");
277    }
278
279    #[test]
280    fn handle_with_intent() {
281        let (result, _) = handle(
282            "python",
283            "print('found 5 errors')",
284            Some("count errors"),
285            None,
286        );
287        assert!(result.contains("found 5 errors"));
288    }
289
290    #[test]
291    fn handle_error_shows_stderr() {
292        let (result, outcome) = handle("python", "raise Exception('boom')", None, None);
293        assert!(result.contains("EXIT"));
294        assert!(result.contains("boom"));
295        assert!(
296            outcome.is_error(),
297            "non-zero sandbox exit must surface as a tool error (#389)"
298        );
299    }
300
301    #[test]
302    #[serial_test::serial]
303    fn shell_execution_cannot_bypass_ctx_shell_allowlist() {
304        // Both guards are needed: `#[serial]` and `test_env_lock` are separate
305        // mutexes. The override below replaces the allowlist wholesale, which
306        // broke `gh391_strict_mode_blocks_substitution_in_args` — a test that
307        // serializes on the lock, not on `#[serial]`.
308        let _env_lock = crate::core::data_dir::test_env_lock();
309        crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "echo");
310        let (result, outcome) = handle("shell", "definitely_not_allowed_command", None, None);
311        crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
312        assert_eq!(outcome, ShellOutcome::Blocked);
313        assert!(
314            result.contains("shell allowlist"),
315            "unexpected result: {result}"
316        );
317    }
318
319    #[test]
320    #[serial_test::serial]
321    fn shell_batch_is_preflighted_before_any_item_runs() {
322        let _env_lock = crate::core::data_dir::test_env_lock();
323        crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "echo");
324        let items = vec![
325            ("shell".to_string(), "echo safe".to_string()),
326            (
327                "shell".to_string(),
328                "definitely_not_allowed_command".to_string(),
329            ),
330        ];
331        let (result, outcome) = handle_batch(&items);
332        crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
333        assert_eq!(outcome, ShellOutcome::Blocked);
334        assert!(result.contains("batch item 2 blocked"));
335    }
336
337    #[test]
338    fn detect_language_from_path() {
339        assert_eq!(detect_language_from_extension("test.py"), "python");
340        assert_eq!(detect_language_from_extension("test.js"), "javascript");
341        assert_eq!(detect_language_from_extension("test.rs"), "rust");
342        assert_eq!(detect_language_from_extension("test.csv"), "python");
343        assert_eq!(detect_language_from_extension("test.log"), "python");
344    }
345
346    #[test]
347    fn escape_shell_dq_handles_special_chars() {
348        assert_eq!(escape_for_shell_dq(r"C:\tmp\file"), r"C:\\tmp\\file");
349        assert_eq!(escape_for_shell_dq("/tmp/normal"), "/tmp/normal");
350        assert_eq!(escape_for_shell_dq("path with $VAR"), r"path with \$VAR");
351        assert_eq!(escape_for_shell_dq(r#"path"quote"#), r#"path\"quote"#);
352        assert_eq!(escape_for_shell_dq("has `backtick`"), r"has \`backtick\`");
353    }
354
355    #[test]
356    fn escape_python_raw_handles_quotes() {
357        assert_eq!(escape_for_python_raw("/tmp/normal"), "/tmp/normal");
358        assert_eq!(escape_for_python_raw(r"C:\Users\test"), r"C:\Users\test");
359    }
360
361    #[test]
362    fn script_with_spaces_in_path() {
363        let script = build_file_processing_script("shell", "test data", None);
364        let lines: Vec<&str> = script.lines().collect();
365        for line in &lines {
366            if line.contains("cat ") || line.contains("rm -f") {
367                assert!(
368                    line.contains('"'),
369                    "path must be double-quoted in shell script: {line}"
370                );
371            }
372        }
373    }
374
375    #[test]
376    #[cfg(not(target_os = "windows"))]
377    fn batch_multiple_tasks() {
378        let items = vec![
379            ("python".to_string(), "print('task1')".to_string()),
380            ("shell".to_string(), "echo task2".to_string()),
381        ];
382        let (result, outcome) = handle_batch(&items);
383        assert!(result.contains("task1"));
384        assert!(result.contains("task2"));
385        assert!(result.contains("2 tasks"));
386        assert_eq!(outcome, ShellOutcome::Exit(0), "all tasks succeeded");
387    }
388
389    #[test]
390    #[cfg(not(target_os = "windows"))]
391    fn batch_with_failing_task_reports_failure() {
392        let items = vec![
393            ("python".to_string(), "print('ok')".to_string()),
394            ("python".to_string(), "raise SystemExit(3)".to_string()),
395        ];
396        let (_, outcome) = handle_batch(&items);
397        assert_eq!(
398            outcome,
399            ShellOutcome::Exit(3),
400            "one failing task marks the whole batch as failed (#389)"
401        );
402    }
403
404    #[test]
405    fn handle_file_precondition_failure_is_blocked() {
406        let (result, outcome) = handle_file("/nonexistent/definitely-missing.py", None, None);
407        assert!(outcome.is_error(), "precondition failures are tool errors");
408        assert_eq!(outcome, ShellOutcome::Blocked, "nothing was executed");
409        assert!(!result.is_empty());
410    }
411}