1use crate::core::sandbox::{self, SandboxResult};
2use crate::core::tokens::count_tokens;
3use crate::server::tool_trait::ShellOutcome;
4
5pub 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
26pub 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
85pub 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
190fn escape_for_python_raw(path: &str) -> String {
193 path.replace('"', r#"\" + '"' + r""#)
194}
195
196fn 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 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}