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::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
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::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
108pub 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 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
217pub 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 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 #[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;