Skip to main content

lean_ctx_server/tools/
ctx_execute.rs

1// SPDX-License-Identifier: Apache-2.0
2use crate::server::tool_trait::ShellOutcome;
3use lean_ctx::core::sandbox::{self, SandboxResult};
4use lean_ctx::core::tokens::count_tokens;
5
6/// The grant key of an approval to run `language` code in `ctx_execute`. It
7/// cannot match a program a `ctx_shell` grant names (no `:` in those).
8pub(crate) fn approval_key(language: &str) -> String {
9    format!("ctx_execute:{language}")
10}
11
12/// The canonical languages among `languages` that run only with the user's
13/// approval (GHSA-rxv5-4c3x-g4q6): every runtime but shell, which follows the
14/// shell allowlist instead. None while `shell_security` is not `enforce`
15/// (nothing is refused then, as in `ctx_shell`) and none the user allowed
16/// for good in `execute_allowed_languages`. In first-seen order, deduplicated.
17pub(crate) fn languages_needing_approval<'a>(
18    languages: impl IntoIterator<Item = &'a str>,
19) -> Vec<&'static str> {
20    use lean_ctx::core::shell_allowlist::ShellSecurity;
21    if ShellSecurity::resolve() != ShellSecurity::Enforce {
22        return Vec::new();
23    }
24    let allowed = lean_ctx::core::config::Config::load().execute_allowed_languages;
25    let mut needed = Vec::new();
26    for language in languages {
27        let Some(canonical) = sandbox::canonical_language(language) else {
28            continue;
29        };
30        if canonical != "shell"
31            && !needed.contains(&canonical)
32            && !allowed
33                .iter()
34                .any(|a| sandbox::canonical_language(a) == Some(canonical))
35        {
36            needed.push(canonical);
37        }
38    }
39    needed
40}
41
42/// Refuses code in a language that needs approval and has none in this
43/// dispatch (`server::shell_approval`), before anything runs.
44pub(crate) fn refuse_unapproved<'a>(
45    languages: impl IntoIterator<Item = &'a str>,
46) -> Result<(), String> {
47    let grant = crate::server::shell_approval::current_grant();
48    let missing: Vec<&str> = languages_needing_approval(languages)
49        .into_iter()
50        .filter(|language| !grant.contains(&approval_key(language)))
51        .collect();
52    if missing.is_empty() {
53        return Ok(());
54    }
55    let list = missing.join(", ");
56    let entries = missing
57        .iter()
58        .map(|l| format!("\"{l}\""))
59        .collect::<Vec<_>>()
60        .join(", ");
61    Err(format!(
62        "[BLOCKED] ctx_execute runs {list} code only after the user approves it, and it was \
63         not approved (the client cannot show approval prompts, approval is off, or another \
64         prompt is open). Ask the user; they can allow it for good with \
65         execute_allowed_languages = [{entries}] in the global lean-ctx config."
66    ))
67}
68
69/// Executes a code snippet in a sandboxed environment.
70/// Returns the formatted output plus the structured outcome so the MCP layer
71/// can set `isError`/`structuredContent` on failures (GitHub #389).
72pub fn handle(
73    language: &str,
74    code: &str,
75    intent: Option<&str>,
76    timeout: Option<u64>,
77) -> (String, ShellOutcome) {
78    handle_in(language, code, intent, timeout, None)
79}
80
81/// Same as [`handle`], but runs the snippet in `cwd` (GH #1666).
82///
83/// `ctx_shell` reroutes an interpreter heredoc here and has already resolved a
84/// working directory for the rest of the same call; passing it on is what keeps
85/// both halves of that call in one directory.
86pub fn handle_in(
87    language: &str,
88    code: &str,
89    intent: Option<&str>,
90    timeout: Option<u64>,
91    cwd: Option<&std::path::Path>,
92) -> (String, ShellOutcome) {
93    let checked = if sandbox::is_shell_language(language) {
94        lean_ctx::core::shell_allowlist::check_shell_allowlist(code)
95    } else {
96        lean_ctx::core::shell_allowlist::refuse_redaction_marker(code)
97    };
98    if let Err(message) = checked {
99        return (message.to_string(), ShellOutcome::Blocked);
100    }
101    let result = sandbox::execute_in(language, code, timeout, cwd);
102    (
103        format_result(&result, intent),
104        ShellOutcome::Exit(result.exit_code),
105    )
106}
107
108/// Summarize admitted file content as data, without executing it or creating
109/// a plaintext temporary copy. Source rules run before statistics or previews.
110pub fn handle_file(
111    path: &str,
112    intent: Option<&str>,
113    project_root: Option<&str>,
114) -> (String, ShellOutcome) {
115    let root = match project_root {
116        Some(root) => std::path::PathBuf::from(root),
117        None => match std::env::current_dir() {
118            Ok(root) => root,
119            Err(_) => return ("Source project unavailable".into(), ShellOutcome::Blocked),
120        },
121    };
122    let result =
123        lean_ctx::core::policy::runtime::with_project_source_view(&root.to_string_lossy(), || {
124            handle_file_in_view(path, intent, &root)
125        });
126    match result {
127        Ok((text, outcome, authority)) => {
128            if let Some(authority) = authority {
129                authority.publish();
130            }
131            (text, outcome)
132        }
133        Err(_) => (
134            "File processing withheld: source authority changed or could not be verified.".into(),
135            ShellOutcome::Blocked,
136        ),
137    }
138}
139
140fn handle_file_in_view(
141    path: &str,
142    intent: Option<&str>,
143    root: &std::path::Path,
144) -> (
145    String,
146    ShellOutcome,
147    Option<lean_ctx::core::archive::authority::ArchiveAuthority>,
148) {
149    let mut remaining = lean_ctx::core::limits::max_read_bytes();
150    let read = match crate::tools::ctx_read::read_file_for_tool_rooted_with_path(
151        path,
152        &root.to_string_lossy(),
153        "ctx_execute",
154        &mut remaining,
155    ) {
156        Ok(content) => content,
157        Err(error) => {
158            return (
159                format!("File processing refused: {error}"),
160                ShellOutcome::Blocked,
161                None,
162            );
163        }
164    };
165    let authority = lean_ctx::core::archive::authority::ArchiveAuthority::file(
166        root,
167        &read.canonical_path,
168        "ctx_execute",
169        &read.content,
170    );
171    (
172        summarize_file_content(&read.content, intent),
173        ShellOutcome::Exit(0),
174        authority,
175    )
176}
177
178fn summarize_file_content(content: &str, intent: Option<&str>) -> String {
179    let lines = content.lines();
180    let count = lines.clone().count();
181    let mut output = format!(
182        "Admitted text: {count} lines, {} bytes, {} words (after decoding and filtering)\n",
183        content.len(),
184        content.split_whitespace().count(),
185    );
186    if let Some(intent) = intent {
187        output.push_str(&format!("Intent: {}\n", sanitize_intent(intent)));
188    }
189    // At most six lines of 1024 Unicode characters each; do not collect the
190    // entire source into a second line buffer just to produce a preview.
191    let append = |output: &mut String, line: &str| {
192        if let Some((end, _)) = line.char_indices().nth(1024) {
193            output.push_str(&line[..end]);
194            output.push_str(" [line truncated]");
195        } else {
196            output.push_str(line);
197        }
198        output.push('\n');
199    };
200    if count <= 6 {
201        for line in lines {
202            append(&mut output, line);
203        }
204    } else {
205        for line in lines.clone().take(3) {
206            append(&mut output, line);
207        }
208        output.push_str(&format!("... {} middle lines omitted ...\n", count - 6));
209        let tail: Vec<_> = lines.rev().take(3).collect();
210        for line in tail.into_iter().rev() {
211            append(&mut output, line);
212        }
213    }
214    output
215}
216
217/// Executes multiple (language, code) pairs in parallel and returns aggregated
218/// results. The outcome carries the first non-zero exit code (0 when all
219/// tasks succeeded), so one failing task marks the whole batch as failed.
220pub fn handle_batch(items: &[(String, String)]) -> (String, ShellOutcome) {
221    for (index, (language, code)) in items.iter().enumerate() {
222        let checked = if sandbox::is_shell_language(language) {
223            lean_ctx::core::shell_allowlist::check_shell_allowlist(code)
224        } else {
225            lean_ctx::core::shell_allowlist::refuse_redaction_marker(code)
226        };
227        if let Err(message) = checked {
228            return (
229                format!(
230                    "batch item {} blocked by shell policy:\n{message}",
231                    index + 1
232                ),
233                ShellOutcome::Blocked,
234            );
235        }
236    }
237    let results = sandbox::batch_execute(items);
238    let mut output = Vec::new();
239
240    for (i, result) in results.iter().enumerate() {
241        let label = format!("[{}/{}] {}", i + 1, results.len(), result.language);
242        if result.exit_code == 0 {
243            let stdout = result.stdout.trim();
244            if stdout.is_empty() {
245                output.push(format!("{label}: (no output) [{} ms]", result.duration_ms));
246            } else {
247                output.push(format!("{label}: {stdout} [{} ms]", result.duration_ms));
248            }
249        } else {
250            let stderr = result.stderr.trim();
251            output.push(format!(
252                "{label}: EXIT {} — {stderr} [{} ms]",
253                result.exit_code, result.duration_ms
254            ));
255        }
256    }
257
258    let total_ms: u64 = results.iter().map(|r| r.duration_ms).sum();
259    output.push(format!("\n{} tasks, {} ms total", results.len(), total_ms));
260    let first_failure = results
261        .iter()
262        .map(|r| r.exit_code)
263        .find(|c| *c != 0)
264        .unwrap_or(0);
265    (output.join("\n"), ShellOutcome::Exit(first_failure))
266}
267
268fn format_result(result: &SandboxResult, intent: Option<&str>) -> String {
269    let mut parts = Vec::new();
270
271    if result.exit_code == 0 {
272        let stdout = result.stdout.trim();
273        if stdout.is_empty() {
274            parts.push("(no output)".to_string());
275        } else {
276            let raw_tokens = count_tokens(stdout);
277            parts.push(stdout.to_string());
278
279            if let Some(intent_desc) = intent
280                && raw_tokens > 50
281            {
282                parts.push(format!("[intent: {intent_desc}]"));
283            }
284        }
285    } else {
286        if !result.stdout.is_empty() {
287            parts.push(result.stdout.trim().to_string());
288        }
289        parts.push(format!(
290            "EXIT {} — {}",
291            result.exit_code,
292            result.stderr.trim()
293        ));
294    }
295
296    parts.push(format!("[{} | {} ms]", result.language, result.duration_ms));
297    parts.join("\n")
298}
299
300fn sanitize_intent(raw: &str) -> String {
301    raw.chars()
302        .filter(|c| c.is_alphanumeric() || *c == ' ' || *c == '-' || *c == '_' || *c == '.')
303        .take(200)
304        .collect()
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn handle_simple_python() {
313        let _lock = lean_ctx::core::data_dir::test_env_lock();
314        let (result, outcome) = handle("python", "print(2 + 2)", None, None);
315        assert!(result.contains('4'));
316        assert!(result.contains("python"));
317        assert_eq!(outcome, ShellOutcome::Exit(0), "success must report exit 0");
318    }
319
320    #[test]
321    fn handle_with_intent() {
322        let _lock = lean_ctx::core::data_dir::test_env_lock();
323        let (result, _) = handle(
324            "python",
325            "print('found 5 errors')",
326            Some("count errors"),
327            None,
328        );
329        assert!(result.contains("found 5 errors"));
330    }
331
332    #[test]
333    fn handle_error_shows_stderr() {
334        let _lock = lean_ctx::core::data_dir::test_env_lock();
335        let (result, outcome) = handle("python", "raise Exception('boom')", None, None);
336        assert!(result.contains("EXIT"));
337        assert!(result.contains("boom"));
338        assert!(
339            outcome.is_error(),
340            "non-zero sandbox exit must surface as a tool error (#389)"
341        );
342    }
343
344    #[test]
345    #[serial_test::serial]
346    fn shell_execution_cannot_bypass_ctx_shell_allowlist() {
347        // Both guards are needed: `#[serial]` and `test_env_lock` are separate
348        // mutexes. The override below replaces the allowlist wholesale, which
349        // broke `gh391_strict_mode_blocks_substitution_in_args` — a test that
350        // serializes on the lock, not on `#[serial]`.
351        let _env_lock = lean_ctx::core::data_dir::test_env_lock();
352        lean_ctx::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "echo");
353        let (result, outcome) = handle("shell", "definitely_not_allowed_command", None, None);
354        lean_ctx::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
355        assert_eq!(outcome, ShellOutcome::Blocked);
356        assert!(
357            result.contains("shell allowlist"),
358            "unexpected result: {result}"
359        );
360    }
361
362    #[test]
363    #[serial_test::serial]
364    fn every_language_but_shell_needs_an_approval_under_enforce() {
365        let _data = lean_ctx::core::data_dir::isolated_data_dir();
366        lean_ctx::test_env::set_var("LEAN_CTX_SHELL_SECURITY", "enforce");
367        assert_eq!(
368            languages_needing_approval(["py", "bash", "PYTHON", "node", "sh", "shell", "cobol"]),
369            ["python", "javascript"]
370        );
371        let refused = refuse_unapproved(["ts"]).unwrap_err();
372        assert!(refused.contains("typescript code"), "{refused}");
373        assert!(refuse_unapproved(["shell", "bash"]).is_ok());
374
375        lean_ctx::test_env::set_var("LEAN_CTX_SHELL_SECURITY", "warn");
376        assert!(
377            languages_needing_approval(["python"]).is_empty(),
378            "nothing is refused outside enforce, as in ctx_shell"
379        );
380        lean_ctx::test_env::remove_var("LEAN_CTX_SHELL_SECURITY");
381    }
382
383    #[test]
384    #[serial_test::serial]
385    fn shell_batch_is_preflighted_before_any_item_runs() {
386        let _env_lock = lean_ctx::core::data_dir::test_env_lock();
387        lean_ctx::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "echo");
388        let items = vec![
389            ("shell".to_string(), "echo safe".to_string()),
390            (
391                "shell".to_string(),
392                "definitely_not_allowed_command".to_string(),
393            ),
394        ];
395        let (result, outcome) = handle_batch(&items);
396        lean_ctx::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
397        assert_eq!(outcome, ShellOutcome::Blocked);
398        assert!(result.contains("batch item 2 blocked"));
399    }
400
401    /// GHSA-rxv5-4c3x-g4q6 B: `bash`/`sh` (any case) resolve to the same shell
402    /// runtime as `shell` and must not skip the allowlist preflight.
403    #[test]
404    #[serial_test::serial]
405    fn shell_aliases_cannot_bypass_ctx_shell_allowlist() {
406        let _env_lock = lean_ctx::core::data_dir::test_env_lock();
407        lean_ctx::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "echo");
408        let single: Vec<_> = ["bash", "sh", "BASH", "Sh", "SHELL"]
409            .iter()
410            .map(|lang| {
411                (
412                    *lang,
413                    handle(lang, "definitely_not_allowed_command", None, None),
414                )
415            })
416            .collect();
417        let batch = handle_batch(&[(
418            "bash".to_string(),
419            "definitely_not_allowed_command".to_string(),
420        )]);
421        lean_ctx::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
422        for (lang, (result, outcome)) in single {
423            assert_eq!(outcome, ShellOutcome::Blocked, "{lang}: {result}");
424            assert!(result.contains("shell allowlist"), "{lang}: {result}");
425        }
426        assert_eq!(batch.1, ShellOutcome::Blocked, "{}", batch.0);
427    }
428
429    #[test]
430    #[cfg(not(target_os = "windows"))]
431    fn batch_multiple_tasks() {
432        let _lock = lean_ctx::core::data_dir::test_env_lock();
433        let items = vec![
434            ("python".to_string(), "print('task1')".to_string()),
435            ("shell".to_string(), "echo task2".to_string()),
436        ];
437        let (result, outcome) = handle_batch(&items);
438        assert!(result.contains("task1"));
439        assert!(result.contains("task2"));
440        assert!(result.contains("2 tasks"));
441        assert_eq!(outcome, ShellOutcome::Exit(0), "all tasks succeeded");
442    }
443
444    #[test]
445    #[cfg(not(target_os = "windows"))]
446    fn batch_with_failing_task_reports_failure() {
447        let _lock = lean_ctx::core::data_dir::test_env_lock();
448        let items = vec![
449            ("python".to_string(), "print('ok')".to_string()),
450            ("python".to_string(), "raise SystemExit(3)".to_string()),
451        ];
452        let (_, outcome) = handle_batch(&items);
453        assert_eq!(
454            outcome,
455            ShellOutcome::Exit(3),
456            "one failing task marks the whole batch as failed (#389)"
457        );
458    }
459
460    #[test]
461    fn handle_file_precondition_failure_is_blocked() {
462        let (result, outcome) = handle_file("/nonexistent/definitely-missing.py", None, None);
463        assert!(outcome.is_error(), "precondition failures are tool errors");
464        assert_eq!(outcome, ShellOutcome::Blocked, "nothing was executed");
465        assert!(!result.is_empty(), "`result` is empty");
466    }
467}
468
469#[cfg(test)]
470#[path = "ctx_execute_file_tests.rs"]
471mod file_tests;