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_shell_policy::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_shell_policy::with_dialect(execute_dialect(), || {
95            lean_ctx_shell_policy::check_shell_allowlist(code)
96        })
97    } else {
98        lean_ctx_shell_policy::refuse_redaction_marker(code)
99    };
100    if let Err(message) = checked {
101        return (message.to_string(), ShellOutcome::Blocked);
102    }
103    let result = sandbox::execute_in(language, code, timeout, cwd);
104    (
105        format_result(&result, intent),
106        ShellOutcome::Exit(result.exit_code),
107    )
108}
109
110/// Summarize admitted file content as data, without executing it or creating
111/// a plaintext temporary copy. Source rules run before statistics or previews.
112pub fn handle_file(
113    path: &str,
114    intent: Option<&str>,
115    project_root: Option<&str>,
116) -> (String, ShellOutcome) {
117    let root = match project_root {
118        Some(root) => std::path::PathBuf::from(root),
119        None => match std::env::current_dir() {
120            Ok(root) => root,
121            Err(_) => return ("Source project unavailable".into(), ShellOutcome::Blocked),
122        },
123    };
124    let result =
125        lean_ctx::core::policy::runtime::with_project_source_view(&root.to_string_lossy(), || {
126            handle_file_in_view(path, intent, &root)
127        });
128    match result {
129        Ok((text, outcome, authority)) => {
130            if let Some(authority) = authority {
131                authority.publish();
132            }
133            (text, outcome)
134        }
135        Err(_) => (
136            "File processing withheld: source authority changed or could not be verified.".into(),
137            ShellOutcome::Blocked,
138        ),
139    }
140}
141
142fn handle_file_in_view(
143    path: &str,
144    intent: Option<&str>,
145    root: &std::path::Path,
146) -> (
147    String,
148    ShellOutcome,
149    Option<lean_ctx::core::archive::authority::ArchiveAuthority>,
150) {
151    let mut remaining = lean_ctx::core::limits::max_read_bytes();
152    let read = match crate::tools::ctx_read::read_file_for_tool_rooted_with_path(
153        path,
154        &root.to_string_lossy(),
155        "ctx_execute",
156        &mut remaining,
157    ) {
158        Ok(content) => content,
159        Err(error) => {
160            return (
161                format!("File processing refused: {error}"),
162                ShellOutcome::Blocked,
163                None,
164            );
165        }
166    };
167    let authority = lean_ctx::core::archive::authority::ArchiveAuthority::file(
168        root,
169        &read.canonical_path,
170        "ctx_execute",
171        &read.content,
172    );
173    (
174        summarize_file_content(&read.content, intent),
175        ShellOutcome::Exit(0),
176        authority,
177    )
178}
179
180fn summarize_file_content(content: &str, intent: Option<&str>) -> String {
181    let lines = content.lines();
182    let count = lines.clone().count();
183    let mut output = format!(
184        "Admitted text: {count} lines, {} bytes, {} words (after decoding and filtering)\n",
185        content.len(),
186        content.split_whitespace().count(),
187    );
188    if let Some(intent) = intent {
189        output.push_str(&format!("Intent: {}\n", sanitize_intent(intent)));
190    }
191    // At most six lines of 1024 Unicode characters each; do not collect the
192    // entire source into a second line buffer just to produce a preview.
193    let append = |output: &mut String, line: &str| {
194        if let Some((end, _)) = line.char_indices().nth(1024) {
195            output.push_str(&line[..end]);
196            output.push_str(" [line truncated]");
197        } else {
198            output.push_str(line);
199        }
200        output.push('\n');
201    };
202    if count <= 6 {
203        for line in lines {
204            append(&mut output, line);
205        }
206    } else {
207        for line in lines.clone().take(3) {
208            append(&mut output, line);
209        }
210        output.push_str(&format!("... {} middle lines omitted ...\n", count - 6));
211        let tail: Vec<_> = lines.rev().take(3).collect();
212        for line in tail.into_iter().rev() {
213            append(&mut output, line);
214        }
215    }
216    output
217}
218
219/// Executes multiple (language, code) pairs in parallel and returns aggregated
220/// results. The outcome carries the first non-zero exit code (0 when all
221/// tasks succeeded), so one failing task marks the whole batch as failed.
222pub fn handle_batch(items: &[(String, String)]) -> (String, ShellOutcome) {
223    handle_batch_in(items, None)
224}
225
226/// Same as [`handle_batch`], but every task runs in `cwd`.
227pub fn handle_batch_in(
228    items: &[(String, String)],
229    cwd: Option<&std::path::Path>,
230) -> (String, ShellOutcome) {
231    for (index, (language, code)) in items.iter().enumerate() {
232        let checked = if sandbox::is_shell_language(language) {
233            lean_ctx_shell_policy::with_dialect(execute_dialect(), || {
234                lean_ctx_shell_policy::check_shell_allowlist(code)
235            })
236        } else {
237            lean_ctx_shell_policy::refuse_redaction_marker(code)
238        };
239        if let Err(message) = checked {
240            return (
241                format!(
242                    "batch item {} blocked by shell policy:\n{message}",
243                    index + 1
244                ),
245                ShellOutcome::Blocked,
246            );
247        }
248    }
249    let results = sandbox::batch_execute_in(items, cwd);
250    let mut output = Vec::new();
251
252    for (i, result) in results.iter().enumerate() {
253        let label = format!("[{}/{}] {}", i + 1, results.len(), result.language);
254        if result.exit_code == 0 {
255            let stdout = result.stdout.trim();
256            if stdout.is_empty() {
257                output.push(format!("{label}: (no output) [{} ms]", result.duration_ms));
258            } else {
259                output.push(format!("{label}: {stdout} [{} ms]", result.duration_ms));
260            }
261        } else {
262            let stderr = result.stderr.trim();
263            output.push(format!(
264                "{label}: EXIT {} — {stderr} [{} ms]",
265                result.exit_code, result.duration_ms
266            ));
267        }
268    }
269
270    let total_ms: u64 = results.iter().map(|r| r.duration_ms).sum();
271    output.push(format!("\n{} tasks, {} ms total", results.len(), total_ms));
272    let first_failure = results
273        .iter()
274        .map(|r| r.exit_code)
275        .find(|c| *c != 0)
276        .unwrap_or(0);
277    (output.join("\n"), ShellOutcome::Exit(first_failure))
278}
279
280fn format_result(result: &SandboxResult, intent: Option<&str>) -> String {
281    let mut parts = Vec::new();
282
283    if result.exit_code == 0 {
284        let stdout = result.stdout.trim();
285        if stdout.is_empty() {
286            parts.push("(no output)".to_string());
287        } else {
288            let raw_tokens = count_tokens(stdout);
289            parts.push(stdout.to_string());
290
291            if let Some(intent_desc) = intent
292                && raw_tokens > 50
293            {
294                parts.push(format!("[intent: {intent_desc}]"));
295            }
296        }
297    } else {
298        if !result.stdout.is_empty() {
299            parts.push(result.stdout.trim().to_string());
300        }
301        parts.push(format!(
302            "EXIT {} — {}",
303            result.exit_code,
304            result.stderr.trim()
305        ));
306    }
307
308    parts.push(format!("[{} | {} ms]", result.language, result.duration_ms));
309    parts.join("\n")
310}
311
312fn sanitize_intent(raw: &str) -> String {
313    raw.chars()
314        .filter(|c| c.is_alphanumeric() || *c == ' ' || *c == '-' || *c == '_' || *c == '.')
315        .take(200)
316        .collect()
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn handle_simple_python() {
325        let _lock = lean_ctx::core::data_dir::test_env_lock();
326        let (result, outcome) = handle("python", "print(2 + 2)", None, None);
327        assert!(result.contains('4'));
328        assert!(result.contains("python"));
329        assert_eq!(outcome, ShellOutcome::Exit(0), "success must report exit 0");
330    }
331
332    #[test]
333    fn handle_with_intent() {
334        let _lock = lean_ctx::core::data_dir::test_env_lock();
335        let (result, _) = handle(
336            "python",
337            "print('found 5 errors')",
338            Some("count errors"),
339            None,
340        );
341        assert!(result.contains("found 5 errors"));
342    }
343
344    #[test]
345    fn handle_error_shows_stderr() {
346        let _lock = lean_ctx::core::data_dir::test_env_lock();
347        let (result, outcome) = handle("python", "raise Exception('boom')", None, None);
348        assert!(result.contains("EXIT"));
349        assert!(result.contains("boom"));
350        assert!(
351            outcome.is_error(),
352            "non-zero sandbox exit must surface as a tool error (#389)"
353        );
354    }
355
356    #[test]
357    #[serial_test::serial]
358    fn shell_execution_cannot_bypass_ctx_shell_allowlist() {
359        // Both guards are needed: `#[serial]` and `test_env_lock` are separate
360        // mutexes. The override below replaces the allowlist wholesale, which
361        // broke `gh391_strict_mode_blocks_substitution_in_args` — a test that
362        // serializes on the lock, not on `#[serial]`.
363        let _env_lock = lean_ctx::core::data_dir::test_env_lock();
364        lean_ctx::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "echo");
365        let (result, outcome) = handle("shell", "definitely_not_allowed_command", None, None);
366        lean_ctx::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
367        assert_eq!(outcome, ShellOutcome::Blocked);
368        assert!(
369            result.contains("shell allowlist"),
370            "unexpected result: {result}"
371        );
372    }
373
374    #[test]
375    #[serial_test::serial]
376    fn every_language_but_shell_needs_an_approval_under_enforce() {
377        let _data = lean_ctx::core::data_dir::isolated_data_dir();
378        lean_ctx::test_env::set_var("LEAN_CTX_SHELL_SECURITY", "enforce");
379        assert_eq!(
380            languages_needing_approval(["py", "bash", "PYTHON", "node", "sh", "shell", "cobol"]),
381            ["python", "javascript"]
382        );
383        let refused = refuse_unapproved(["ts"]).unwrap_err();
384        assert!(refused.contains("typescript code"), "{refused}");
385        assert!(refuse_unapproved(["shell", "bash"]).is_ok());
386
387        lean_ctx::test_env::set_var("LEAN_CTX_SHELL_SECURITY", "warn");
388        assert!(
389            languages_needing_approval(["python"]).is_empty(),
390            "nothing is refused outside enforce, as in ctx_shell"
391        );
392        lean_ctx::test_env::remove_var("LEAN_CTX_SHELL_SECURITY");
393    }
394
395    #[test]
396    #[serial_test::serial]
397    fn shell_batch_is_preflighted_before_any_item_runs() {
398        let _env_lock = lean_ctx::core::data_dir::test_env_lock();
399        lean_ctx::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "echo");
400        let items = vec![
401            ("shell".to_string(), "echo safe".to_string()),
402            (
403                "shell".to_string(),
404                "definitely_not_allowed_command".to_string(),
405            ),
406        ];
407        let (result, outcome) = handle_batch(&items);
408        lean_ctx::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
409        assert_eq!(outcome, ShellOutcome::Blocked);
410        assert!(result.contains("batch item 2 blocked"));
411    }
412
413    /// GHSA-rxv5-4c3x-g4q6 B: `bash`/`sh` (any case) resolve to the same shell
414    /// runtime as `shell` and must not skip the allowlist preflight.
415    #[test]
416    #[serial_test::serial]
417    fn shell_aliases_cannot_bypass_ctx_shell_allowlist() {
418        let _env_lock = lean_ctx::core::data_dir::test_env_lock();
419        lean_ctx::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "echo");
420        let single: Vec<_> = ["bash", "sh", "BASH", "Sh", "SHELL"]
421            .iter()
422            .map(|lang| {
423                (
424                    *lang,
425                    handle(lang, "definitely_not_allowed_command", None, None),
426                )
427            })
428            .collect();
429        let batch = handle_batch(&[(
430            "bash".to_string(),
431            "definitely_not_allowed_command".to_string(),
432        )]);
433        lean_ctx::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
434        for (lang, (result, outcome)) in single {
435            assert_eq!(outcome, ShellOutcome::Blocked, "{lang}: {result}");
436            assert!(result.contains("shell allowlist"), "{lang}: {result}");
437        }
438        assert_eq!(batch.1, ShellOutcome::Blocked, "{}", batch.0);
439    }
440
441    /// The code runs under `bash -c`, so `$BASH = cargo` runs `$BASH` with
442    /// arguments even when this process resolves PowerShell for `ctx_shell`.
443    #[test]
444    #[serial_test::serial]
445    #[cfg(unix)]
446    fn shell_execution_reads_the_line_as_bash_even_under_a_powershell_process() {
447        let _env_lock = lean_ctx::core::data_dir::test_env_lock();
448        lean_ctx::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "cargo");
449        lean_ctx::test_env::set_var("LEAN_CTX_SHELL", "pwsh");
450        let single = handle("shell", "$BASH = cargo", None, None);
451        let batch = handle_batch(&[("bash".to_string(), "$BASH = cargo".to_string())]);
452        lean_ctx::test_env::remove_var("LEAN_CTX_SHELL");
453        lean_ctx::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
454        assert_eq!(single.1, ShellOutcome::Blocked, "{}", single.0);
455        assert_eq!(batch.1, ShellOutcome::Blocked, "{}", batch.0);
456    }
457
458    #[test]
459    #[cfg(not(target_os = "windows"))]
460    fn batch_multiple_tasks() {
461        let _lock = lean_ctx::core::data_dir::test_env_lock();
462        let items = vec![
463            ("python".to_string(), "print('task1')".to_string()),
464            ("shell".to_string(), "echo task2".to_string()),
465        ];
466        let (result, outcome) = handle_batch(&items);
467        assert!(result.contains("task1"));
468        assert!(result.contains("task2"));
469        assert!(result.contains("2 tasks"));
470        assert_eq!(outcome, ShellOutcome::Exit(0), "all tasks succeeded");
471    }
472
473    #[test]
474    #[cfg(not(target_os = "windows"))]
475    fn batch_with_failing_task_reports_failure() {
476        let _lock = lean_ctx::core::data_dir::test_env_lock();
477        let items = vec![
478            ("python".to_string(), "print('ok')".to_string()),
479            ("python".to_string(), "raise SystemExit(3)".to_string()),
480        ];
481        let (_, outcome) = handle_batch(&items);
482        assert_eq!(
483            outcome,
484            ShellOutcome::Exit(3),
485            "one failing task marks the whole batch as failed (#389)"
486        );
487    }
488
489    #[test]
490    fn handle_file_precondition_failure_is_blocked() {
491        let (result, outcome) = handle_file("/nonexistent/definitely-missing.py", None, None);
492        assert!(outcome.is_error(), "precondition failures are tool errors");
493        assert_eq!(outcome, ShellOutcome::Blocked, "nothing was executed");
494        assert!(!result.is_empty(), "`result` is empty");
495    }
496}
497
498#[cfg(test)]
499#[path = "ctx_execute_file_tests.rs"]
500mod file_tests;
501
502/// `ctx_execute` runs shell code with `bash -c` on Unix and `cmd` on Windows,
503/// whatever shell this process resolves for `ctx_shell`.
504fn execute_dialect() -> lean_ctx_shell_policy::Dialect {
505    if cfg!(windows) {
506        lean_ctx_shell_policy::Dialect::Cmd
507    } else {
508        lean_ctx_shell_policy::Dialect::Posix
509    }
510}