1use crate::server::tool_trait::ShellOutcome;
3use lean_ctx::core::sandbox::{self, SandboxResult};
4use lean_ctx::core::tokens::count_tokens;
5
6pub(crate) fn approval_key(language: &str) -> String {
9 format!("ctx_execute:{language}")
10}
11
12pub(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
42pub(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
69pub 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
81pub 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
110pub 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 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
219pub fn handle_batch(items: &[(String, String)]) -> (String, ShellOutcome) {
223 handle_batch_in(items, None)
224}
225
226pub 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 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 #[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 #[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
502fn 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}