1use std::collections::HashMap;
2#[cfg(unix)]
3use std::os::unix::process::CommandExt;
4use std::process::Command;
5
6#[derive(Debug, Clone)]
7pub struct SandboxResult {
8 pub stdout: String,
9 pub stderr: String,
10 pub exit_code: i32,
11 pub language: String,
12 pub duration_ms: u64,
13}
14
15const TIMEOUT_SECS: u64 = 30;
16const MAX_OUTPUT_BYTES: usize = 32_768;
17const MAX_CODE_BYTES: usize = 256 * 1024;
20
21pub fn execute(language: &str, code: &str, timeout_secs: Option<u64>) -> SandboxResult {
28 execute_in(language, code, timeout_secs, None)
29}
30
31pub fn execute_in(
39 language: &str,
40 code: &str,
41 timeout_secs: Option<u64>,
42 cwd: Option<&std::path::Path>,
43) -> SandboxResult {
44 if code.len() > MAX_CODE_BYTES {
45 return SandboxResult {
46 stdout: String::new(),
47 stderr: format!(
48 "Code exceeds the {MAX_CODE_BYTES}-byte limit ({} bytes). Split it into smaller scripts.",
49 code.len()
50 ),
51 exit_code: 1,
52 language: language.to_string(),
53 duration_ms: 0,
54 };
55 }
56
57 let timeout = timeout_secs.unwrap_or(TIMEOUT_SECS);
58 let start = std::time::Instant::now();
59
60 let Some(runtime) = resolve_runtime(language) else {
61 return SandboxResult {
62 stdout: String::new(),
63 stderr: format!(
64 "Unsupported language: {language}. Supported: javascript, typescript, python, shell, ruby, go, rust, php, perl, r, elixir"
65 ),
66 exit_code: 1,
67 language: language.to_string(),
68 duration_ms: 0,
69 };
70 };
71
72 let sandbox_level = std::env::var("LEAN_CTX_SANDBOX_LEVEL")
73 .ok()
74 .and_then(|v| v.parse::<u8>().ok())
75 .unwrap_or_else(|| crate::core::config::Config::load().sandbox_level);
76
77 if sandbox_level >= 1 && cfg!(target_os = "macos") {
78 let result = seatbelt_execute(&runtime, code, timeout, cwd);
79 let duration_ms = start.elapsed().as_millis() as u64;
80 return match result {
81 Ok((stdout, stderr, exit_code)) => SandboxResult {
82 stdout: truncate_output(&stdout),
83 stderr: truncate_smart(&stderr, 2048),
84 exit_code,
85 language: language.to_string(),
86 duration_ms,
87 },
88 Err(e) => SandboxResult {
89 stdout: String::new(),
90 stderr: format!("Seatbelt execution error: {e}"),
91 exit_code: 1,
92 language: language.to_string(),
93 duration_ms,
94 },
95 };
96 } else if sandbox_level >= 1 {
97 #[cfg(target_os = "linux")]
98 {
99 let result = landlock_execute(&runtime, code, timeout, cwd);
100 let duration_ms = start.elapsed().as_millis() as u64;
101 return match result {
102 Ok((stdout, stderr, exit_code)) => SandboxResult {
103 stdout: truncate_output(&stdout),
104 stderr: truncate_smart(&stderr, 2048),
105 exit_code,
106 language: language.to_string(),
107 duration_ms,
108 },
109 Err(e) => SandboxResult {
110 stdout: String::new(),
111 stderr: format!("Landlock execution error: {e}"),
112 exit_code: 1,
113 language: language.to_string(),
114 duration_ms,
115 },
116 };
117 }
118
119 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
123 {
124 return sandbox_unavailable(language, start);
125 }
126 }
127
128 let result = if runtime.needs_temp_file {
129 execute_with_file(&runtime, code, timeout, cwd)
130 } else {
131 execute_with_stdin(&runtime, code, timeout, cwd)
132 };
133
134 let duration_ms = start.elapsed().as_millis() as u64;
135
136 match result {
137 Ok((stdout, stderr, code)) => SandboxResult {
138 stdout: truncate_output(&stdout),
139 stderr: truncate_smart(&stderr, 2048),
140 exit_code: code,
141 language: language.to_string(),
142 duration_ms,
143 },
144 Err(e) => SandboxResult {
145 stdout: String::new(),
146 stderr: format!("Execution error: {e}"),
147 exit_code: 1,
148 language: language.to_string(),
149 duration_ms,
150 },
151 }
152}
153
154pub fn batch_execute(items: &[(String, String)]) -> Vec<SandboxResult> {
155 batch_execute_in(items, None)
156}
157
158pub fn batch_execute_in(
160 items: &[(String, String)],
161 cwd: Option<&std::path::Path>,
162) -> Vec<SandboxResult> {
163 items
164 .iter()
165 .map(|(lang, code)| execute_in(lang, code, None, cwd))
166 .collect()
167}
168
169#[must_use]
173pub fn is_shell_language(language: &str) -> bool {
174 matches!(language.to_lowercase().as_str(), "shell" | "bash" | "sh")
175}
176
177#[cfg_attr(any(target_os = "macos", target_os = "linux"), allow(dead_code))]
179fn sandbox_unavailable(language: &str, start: std::time::Instant) -> SandboxResult {
180 SandboxResult {
181 stdout: String::new(),
182 stderr: "sandbox_level >= 1 is configured but no OS sandbox is available on this \
183 platform; refusing to run the code unsandboxed. Set sandbox_level = 0 to \
184 accept unsandboxed execution."
185 .to_string(),
186 exit_code: 1,
187 language: language.to_string(),
188 duration_ms: start.elapsed().as_millis() as u64,
189 }
190}
191
192struct RuntimeConfig {
193 command: String,
194 args: Vec<String>,
195 needs_temp_file: bool,
196 file_extension: String,
197 env: HashMap<String, String>,
198}
199
200pub fn canonical_language(language: &str) -> Option<&'static str> {
203 Some(match language.to_lowercase().as_str() {
204 "javascript" | "js" | "node" => "javascript",
205 "typescript" | "ts" => "typescript",
206 "python" | "py" => "python",
207 "shell" | "bash" | "sh" => "shell",
208 "ruby" | "rb" => "ruby",
209 "go" | "golang" => "go",
210 "rust" | "rs" => "rust",
211 "php" => "php",
212 "perl" | "pl" => "perl",
213 "r" => "r",
214 "elixir" | "ex" => "elixir",
215 _ => return None,
216 })
217}
218
219fn resolve_runtime(language: &str) -> Option<RuntimeConfig> {
220 match canonical_language(language)? {
221 "javascript" => Some(RuntimeConfig {
222 command: find_binary(&["bun", "node"])?,
223 args: vec!["-e".to_string()],
224 needs_temp_file: false,
225 file_extension: "js".to_string(),
226 env: HashMap::new(),
227 }),
228 "typescript" => Some(RuntimeConfig {
229 command: find_binary(&["bun", "npx"])?,
230 args: if which_exists("bun") {
231 vec!["-e".to_string()]
232 } else {
233 vec!["tsx".to_string(), "-e".to_string()]
234 },
235 needs_temp_file: false,
236 file_extension: "ts".to_string(),
237 env: HashMap::new(),
238 }),
239 "python" => Some(RuntimeConfig {
240 command: find_binary(&["python3", "python"])?,
241 args: vec!["-c".to_string()],
242 needs_temp_file: false,
243 file_extension: "py".to_string(),
244 env: HashMap::from([("PYTHONDONTWRITEBYTECODE".into(), "1".into())]),
245 }),
246 lang if is_shell_language(lang) => {
247 #[cfg(target_os = "windows")]
248 {
249 Some(RuntimeConfig {
250 command: "cmd".to_string(),
251 args: vec!["/C".to_string()],
252 needs_temp_file: false,
253 file_extension: "bat".to_string(),
254 env: HashMap::new(),
255 })
256 }
257 #[cfg(not(target_os = "windows"))]
258 {
259 Some(RuntimeConfig {
260 command: find_binary(&["bash", "sh"])?,
261 args: vec!["-c".to_string()],
262 needs_temp_file: false,
263 file_extension: "sh".to_string(),
264 env: HashMap::new(),
265 })
266 }
267 }
268 "ruby" => Some(RuntimeConfig {
269 command: find_binary(&["ruby"])?,
270 args: vec!["-e".to_string()],
271 needs_temp_file: false,
272 file_extension: "rb".to_string(),
273 env: HashMap::new(),
274 }),
275 "go" => Some(RuntimeConfig {
276 command: find_binary(&["go"])?,
277 args: vec!["run".to_string()],
278 needs_temp_file: true,
279 file_extension: "go".to_string(),
280 env: HashMap::new(),
281 }),
282 "rust" => Some(RuntimeConfig {
283 command: "rustc_script".to_string(),
284 args: vec![],
285 needs_temp_file: true,
286 file_extension: "rs".to_string(),
287 env: HashMap::new(),
288 }),
289 "php" => Some(RuntimeConfig {
290 command: find_binary(&["php"])?,
291 args: vec!["-r".to_string()],
292 needs_temp_file: false,
293 file_extension: "php".to_string(),
294 env: HashMap::new(),
295 }),
296 "perl" => Some(RuntimeConfig {
297 command: find_binary(&["perl"])?,
298 args: vec!["-e".to_string()],
299 needs_temp_file: false,
300 file_extension: "pl".to_string(),
301 env: HashMap::new(),
302 }),
303 "r" => Some(RuntimeConfig {
304 command: find_binary(&["Rscript"])?,
305 args: vec!["-e".to_string()],
306 needs_temp_file: false,
307 file_extension: "R".to_string(),
308 env: HashMap::new(),
309 }),
310 "elixir" => Some(RuntimeConfig {
311 command: find_binary(&["elixir"])?,
312 args: vec!["-e".to_string()],
313 needs_temp_file: false,
314 file_extension: "exs".to_string(),
315 env: HashMap::new(),
316 }),
317 _ => None,
318 }
319}
320
321fn seatbelt_execute(
322 runtime: &RuntimeConfig,
323 code: &str,
324 timeout: u64,
325 cwd: Option<&std::path::Path>,
326) -> Result<(String, String, i32), String> {
327 let tmp_dir = std::env::temp_dir().join("lean-ctx-sandbox");
328 let _ = std::fs::create_dir_all(&tmp_dir);
329
330 let env_pairs: Vec<(String, String)> = runtime
331 .env
332 .iter()
333 .map(|(k, v)| (k.clone(), v.clone()))
334 .collect();
335
336 if runtime.needs_temp_file {
337 let suffix = format!(".{}", runtime.file_extension);
338 let tmp = tempfile::Builder::new()
339 .prefix("exec_")
340 .suffix(&suffix)
341 .tempfile_in(&tmp_dir)
342 .map_err(|e| format!("Failed to create temp file: {e}"))?;
343 let file_path = tmp.into_temp_path();
344 std::fs::write(&file_path, code).map_err(|e| format!("Failed to write temp file: {e}"))?;
345
346 let mut allowed = vec![file_path.to_path_buf()];
347 if let Some(dir) = cwd {
352 allowed.push(dir.to_path_buf());
353 }
354 let allowed_refs: Vec<&std::path::Path> =
355 allowed.iter().map(std::path::PathBuf::as_path).collect();
356 let file_str = file_path.to_string_lossy().to_string();
357
358 let mut args: Vec<&str> = runtime
359 .args
360 .iter()
361 .map(std::string::String::as_str)
362 .collect();
363 args.push(&file_str);
364
365 let result = super::sandbox_seatbelt::execute_sandboxed(
366 &runtime.command,
367 &args,
368 &allowed_refs,
369 &env_pairs,
370 timeout,
371 cwd,
372 );
373 let _ = std::fs::remove_file(&file_path);
374 result
375 } else {
376 let mut args: Vec<&str> = runtime
377 .args
378 .iter()
379 .map(std::string::String::as_str)
380 .collect();
381 args.push(code);
382 let allowed: Vec<std::path::PathBuf> =
383 cwd.map(|d| vec![d.to_path_buf()]).unwrap_or_default();
384 let allowed_refs: Vec<&std::path::Path> =
385 allowed.iter().map(std::path::PathBuf::as_path).collect();
386 super::sandbox_seatbelt::execute_sandboxed(
387 &runtime.command,
388 &args,
389 &allowed_refs,
390 &env_pairs,
391 timeout,
392 cwd,
393 )
394 }
395}
396
397#[cfg(target_os = "linux")]
398fn landlock_execute(
399 runtime: &RuntimeConfig,
400 code: &str,
401 timeout: u64,
402 cwd: Option<&std::path::Path>,
403) -> Result<(String, String, i32), String> {
404 let tmp_dir = std::env::temp_dir().join("lean-ctx-sandbox");
405 let _ = std::fs::create_dir_all(&tmp_dir);
406
407 let env_pairs: Vec<(String, String)> = runtime
408 .env
409 .iter()
410 .map(|(k, v)| (k.clone(), v.clone()))
411 .collect();
412
413 if runtime.needs_temp_file {
414 let suffix = format!(".{}", runtime.file_extension);
415 let tmp = tempfile::Builder::new()
416 .prefix("exec_")
417 .suffix(&suffix)
418 .tempfile_in(&tmp_dir)
419 .map_err(|e| format!("Failed to create temp file: {e}"))?;
420 let file_path = tmp.into_temp_path();
421 std::fs::write(&file_path, code).map_err(|e| format!("Failed to write temp file: {e}"))?;
422
423 let mut allowed = vec![file_path.to_path_buf()];
424 if let Some(dir) = cwd {
425 allowed.push(dir.to_path_buf());
426 }
427 let allowed_refs: Vec<&std::path::Path> =
428 allowed.iter().map(std::path::PathBuf::as_path).collect();
429 let file_str = file_path.to_string_lossy().to_string();
430
431 let mut args: Vec<&str> = runtime
432 .args
433 .iter()
434 .map(std::string::String::as_str)
435 .collect();
436 args.push(&file_str);
437
438 let result = super::sandbox_landlock::execute_sandboxed(
439 &runtime.command,
440 &args,
441 &allowed_refs,
442 &env_pairs,
443 timeout,
444 cwd,
445 );
446 let _ = std::fs::remove_file(&file_path);
447 result
448 } else {
449 let mut args: Vec<&str> = runtime
450 .args
451 .iter()
452 .map(std::string::String::as_str)
453 .collect();
454 args.push(code);
455 let allowed: Vec<std::path::PathBuf> =
456 cwd.map(|d| vec![d.to_path_buf()]).unwrap_or_default();
457 let allowed_refs: Vec<&std::path::Path> =
458 allowed.iter().map(std::path::PathBuf::as_path).collect();
459 super::sandbox_landlock::execute_sandboxed(
460 &runtime.command,
461 &args,
462 &allowed_refs,
463 &env_pairs,
464 timeout,
465 cwd,
466 )
467 }
468}
469
470const SANDBOX_ENV_ALLOWLIST: &[&str] = &[
471 "PATH",
472 "HOME",
473 "USER",
474 "LANG",
475 "LC_ALL",
476 "TERM",
477 "TMPDIR",
478 "TMP",
479 "TEMP",
480 "SYSTEMROOT",
481 "WINDIR",
482 "SystemDrive",
483];
484
485fn apply_sandbox_env(cmd: &mut Command, runtime: &RuntimeConfig) {
486 cmd.env_clear();
487 for key in SANDBOX_ENV_ALLOWLIST {
488 if let Ok(val) = std::env::var(key) {
489 cmd.env(key, val);
490 }
491 }
492 for (k, v) in &runtime.env {
493 cmd.env(k, v);
494 }
495 cmd.env("LEAN_CTX_SANDBOX", "1");
496}
497
498fn apply_cwd(cmd: &mut Command, cwd: Option<&std::path::Path>) -> Result<(), String> {
506 let Some(dir) = cwd else { return Ok(()) };
507 if !dir.is_dir() {
508 return Err(format!(
509 "working directory does not exist: {}",
510 dir.display()
511 ));
512 }
513 cmd.current_dir(dir);
514 Ok(())
515}
516
517fn execute_with_stdin(
518 runtime: &RuntimeConfig,
519 code: &str,
520 timeout: u64,
521 cwd: Option<&std::path::Path>,
522) -> Result<(String, String, i32), String> {
523 let mut cmd = Command::new(&runtime.command);
524 apply_cwd(&mut cmd, cwd)?;
525 for arg in &runtime.args {
526 cmd.arg(arg);
527 }
528 cmd.arg(code);
529 apply_sandbox_env(&mut cmd, runtime);
530 run_runtime_command(cmd, timeout)
531}
532
533fn run_runtime_command(mut cmd: Command, timeout: u64) -> Result<(String, String, i32), String> {
537 #[cfg(unix)]
538 {
539 use crate::core::protected_execution::{Mode, launch_mode};
540 match launch_mode()? {
541 Mode::Brokered(client) => {
542 let executable = resolve_program_path(&cmd)?;
543 let executable = executable.to_str().ok_or("runtime path is not UTF-8")?;
544 let args: Vec<&str> = cmd
545 .get_args()
546 .map(|arg| arg.to_str().ok_or("runtime argument is not UTF-8"))
547 .collect::<Result<_, _>>()?;
548 let env: Vec<(String, String)> = cmd
549 .get_envs()
550 .filter_map(|(key, value)| value.map(|value| (key, value)))
551 .map(|(key, value)| {
552 Ok((
553 key.to_str()
554 .ok_or("runtime environment key is not UTF-8")?
555 .to_owned(),
556 value
557 .to_str()
558 .ok_or("runtime environment value is not UTF-8")?
559 .to_owned(),
560 ))
561 })
562 .collect::<Result<_, String>>()?;
563 return client.session_program(
564 executable,
565 &args,
566 &env,
567 timeout,
568 cmd.get_current_dir(),
569 );
570 }
571 Mode::Sandboxed(_) => {
572 return Err("runtime execution must use the supervised program channel".into());
573 }
574 Mode::Direct => {}
575 }
576 }
577 #[cfg(not(unix))]
578 let _protected = crate::core::protected_store::protected_child_prefix()?;
579 cmd.stdin(std::process::Stdio::null());
583 cmd.stdout(std::process::Stdio::piped());
584 cmd.stderr(std::process::Stdio::piped());
585 #[cfg(unix)]
586 cmd.process_group(0);
587
588 let program = cmd.get_program().to_string_lossy().into_owned();
589 let output = wait_with_timeout(&mut cmd, timeout, |e| {
590 format!("Failed to spawn {program}: {e}")
591 })?;
592 Ok((
593 crate::shell::decode_output(&output.stdout),
594 crate::shell::decode_output(&output.stderr),
595 crate::shell::exit_status::exit_code(output.status),
596 ))
597}
598
599#[cfg(unix)]
600fn resolve_program_path(cmd: &Command) -> Result<std::path::PathBuf, String> {
601 use std::os::unix::fs::PermissionsExt;
602 let program = std::path::Path::new(cmd.get_program());
603 if program.is_absolute() {
604 return Ok(program.to_path_buf());
605 }
606 let cwd = cmd
607 .get_current_dir()
608 .map(std::path::Path::to_path_buf)
609 .unwrap_or(std::env::current_dir().map_err(|_| "runtime working directory unavailable")?);
610 if program.components().count() > 1 {
611 return Ok(cwd.join(program));
612 }
613 let path = cmd
614 .get_envs()
615 .find(|(key, _)| *key == "PATH")
616 .and_then(|(_, value)| value.map(std::ffi::OsStr::to_os_string))
617 .or_else(|| std::env::var_os("PATH"))
618 .ok_or("runtime PATH unavailable")?;
619 for directory in std::env::split_paths(&path) {
620 let directory = if directory.is_absolute() {
621 directory
622 } else {
623 cwd.join(directory)
624 };
625 let candidate = directory.join(program);
626 if candidate
627 .metadata()
628 .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
629 {
630 return Ok(candidate);
632 }
633 }
634 Err("runtime executable unavailable".into())
635}
636
637fn execute_with_file(
638 runtime: &RuntimeConfig,
639 code: &str,
640 timeout: u64,
641 cwd: Option<&std::path::Path>,
642) -> Result<(String, String, i32), String> {
643 let tmp_dir = std::env::temp_dir().join("lean-ctx-sandbox");
644 let _ = std::fs::create_dir_all(&tmp_dir);
645
646 let suffix = format!(".{}", runtime.file_extension);
647 let tmp = tempfile::Builder::new()
648 .prefix("exec_")
649 .suffix(&suffix)
650 .tempfile_in(&tmp_dir)
651 .map_err(|e| format!("Failed to create temp file: {e}"))?;
652 let file_path = tmp.into_temp_path();
653
654 std::fs::write(&file_path, code).map_err(|e| format!("Failed to write temp file: {e}"))?;
655
656 let result = if runtime.command == "rustc_script" {
657 execute_rust(&file_path, timeout, cwd)
658 } else {
659 let mut cmd = Command::new(&runtime.command);
660 apply_cwd(&mut cmd, cwd)?;
661 for arg in &runtime.args {
662 cmd.arg(arg);
663 }
664 cmd.arg(&file_path);
665 apply_sandbox_env(&mut cmd, runtime);
666 run_runtime_command(cmd, timeout)
667 };
668
669 let _ = std::fs::remove_file(&file_path);
670 result
671}
672
673fn execute_rust(
674 source_path: &std::path::Path,
675 timeout: u64,
676 cwd: Option<&std::path::Path>,
677) -> Result<(String, String, i32), String> {
678 let binary_path = source_path.with_extension("");
679
680 let mut compile_cmd = Command::new("rustc");
681 compile_cmd.arg(source_path).arg("-o").arg(&binary_path);
682 compile_cmd.env_clear();
683 for key in SANDBOX_ENV_ALLOWLIST {
684 if let Ok(val) = std::env::var(key) {
685 compile_cmd.env(key, val);
686 }
687 }
688 compile_cmd.env("LEAN_CTX_SANDBOX", "1");
689
690 let (_, stderr, code) = run_runtime_command(compile_cmd, timeout)?;
691
692 if code != 0 {
693 let _ = std::fs::remove_file(&binary_path);
694 return Ok((String::new(), stderr, code));
695 }
696
697 let mut run_cmd = Command::new(&binary_path);
698 apply_cwd(&mut run_cmd, cwd)?;
699 run_cmd.env_clear();
700 for key in SANDBOX_ENV_ALLOWLIST {
701 if let Ok(val) = std::env::var(key) {
702 run_cmd.env(key, val);
703 }
704 }
705 run_cmd.env("LEAN_CTX_SANDBOX", "1");
706 let result = run_runtime_command(run_cmd, timeout);
707 let _ = std::fs::remove_file(&binary_path);
708 result
709}
710
711#[cfg(unix)]
714fn let_go_of_guard(guard: &mut Option<crate::shell::containment::CallGuard>) {
715 if let Some(guard) = guard.take() {
716 let ended = guard.finish();
717 if ended != crate::shell::containment::GuardOutcome::Ended {
718 tracing::warn!("sandbox run: the end of its process group is {ended:?}");
719 }
720 }
721}
722
723fn wait_with_timeout(
724 cmd: &mut Command,
725 timeout_secs: u64,
726 spawn_error: impl FnOnce(std::io::Error) -> String,
727) -> Result<std::process::Output, String> {
728 crate::shell::process_tree::ProcessTree::prepare(cmd);
731 #[cfg(unix)]
739 let (mut child, mut guard) = {
740 use crate::shell::containment::Refusal;
741 match crate::shell::containment::start_guarded_child(cmd) {
742 Ok(started) => started,
743 Err(Refusal::NotStarted(error)) => return Err(spawn_error(error)),
744 Err(Refusal::NotLeader) => {
745 return Err(
746 "Execution could not be contained: the process does not lead a group of its own"
747 .to_string(),
748 );
749 }
750 Err(Refusal::NoGuard(reason)) => {
751 return Err(format!("Execution could not be contained: {reason}"));
752 }
753 }
754 };
755 #[cfg(not(unix))]
756 let mut child = cmd.spawn().map_err(spawn_error)?;
757 let tree = crate::shell::process_tree::ProcessTree::start(&mut child)?;
758 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
759
760 loop {
761 match child.try_wait() {
762 Ok(Some(_)) => {
763 tree.release();
764 #[cfg(unix)]
768 let_go_of_guard(&mut guard);
769 return child.wait_with_output().map_err(|e| e.to_string());
770 }
771 Ok(None) => {
772 if std::time::Instant::now() > deadline {
773 tree.kill(&mut child);
774 #[cfg(unix)]
775 let_go_of_guard(&mut guard);
776 if let Ok(mut output) = child.wait_with_output() {
780 output.stderr.extend_from_slice(
781 format!("\n[lean-ctx] Execution timed out after {timeout_secs}s")
782 .as_bytes(),
783 );
784 return Ok(output);
785 }
786 return Err(format!("Execution timed out after {timeout_secs}s"));
787 }
788 std::thread::sleep(std::time::Duration::from_millis(50));
789 }
790 Err(e) => {
791 tree.kill(&mut child);
792 #[cfg(unix)]
793 let_go_of_guard(&mut guard);
794 let _ = child.wait();
795 return Err(e.to_string());
796 }
797 }
798 }
799}
800
801fn find_binary(candidates: &[&str]) -> Option<String> {
802 for name in candidates {
803 if which_exists(name) {
804 return Some(name.to_string());
805 }
806 }
807 None
808}
809
810fn which_exists(name: &str) -> bool {
811 crate::core::pathutil::find_on_path(name).is_some()
812}
813
814fn truncate_output(output: &str) -> String {
815 if output.len() <= MAX_OUTPUT_BYTES {
816 return output.to_string();
817 }
818 crate::core::recovery::with_original_pointer(
819 "ctx_execute",
820 "stdout",
821 output,
822 truncate_smart(output, MAX_OUTPUT_BYTES),
823 )
824}
825
826fn truncate_smart(output: &str, max_bytes: usize) -> String {
827 if output.len() <= max_bytes {
828 return output.to_string();
829 }
830
831 let lines: Vec<&str> = output.lines().collect();
832 let total_lines = lines.len();
833
834 let head_count = (total_lines * 60) / 100;
835 let tail_count = total_lines - head_count;
836
837 let head: Vec<&str> = lines.iter().take(head_count).copied().collect();
838 let tail: Vec<&str> = lines
839 .iter()
840 .skip(total_lines - tail_count)
841 .copied()
842 .collect();
843
844 let head_text = head.join("\n");
845 let tail_text = tail.join("\n");
846
847 if head_text.len() + tail_text.len() + 100 > max_bytes {
848 let half = max_bytes / 2;
849 let h = &output[..output.floor_char_boundary(half.min(output.len()))];
850 let t_start = output.ceil_char_boundary(output.len().saturating_sub(half));
851 let t = &output[t_start..];
852 let skipped = output.len() - h.len() - t.len();
853 return format!("{h}\n\n... [{skipped} bytes truncated — showing head + tail] ...\n\n{t}");
854 }
855
856 let skipped_lines = total_lines - head_count - tail_count;
857 let skipped_bytes = output.len() - head_text.len() - tail_text.len();
858 format!(
859 "{head_text}\n\n... [{skipped_lines} lines / {skipped_bytes} bytes truncated — showing first {head_count} + last {tail_count} lines] ...\n\n{tail_text}"
860 )
861}
862
863pub fn supported_languages() -> &'static [&'static str] {
864 &[
865 "javascript",
866 "typescript",
867 "python",
868 "shell",
869 "ruby",
870 "go",
871 "rust",
872 "php",
873 "perl",
874 "r",
875 "elixir",
876 ]
877}
878
879#[cfg(test)]
880pub mod tests {
881 use super::*;
882
883 fn python_available() -> bool {
884 find_binary(&["python3", "python"]).is_some()
885 }
886
887 #[cfg(target_os = "macos")]
888 #[test]
889 fn level_zero_interpreters_and_compilers_require_the_protected_channel() {
890 let _lock = crate::core::data_dir::test_env_lock();
891 let isolation = crate::core::data_dir::isolated_data_dir();
892 let _session =
893 crate::core::protected_store::pin_synthetic_session(isolation.path()).unwrap();
894 let runtime = RuntimeConfig {
895 command: "/bin/sh".into(),
896 args: vec!["-c".into()],
897 needs_temp_file: false,
898 file_extension: "sh".into(),
899 env: HashMap::new(),
900 };
901 let stdin_error = execute_with_stdin(&runtime, "printf bypass", 1, None).unwrap_err();
902 assert!(
903 stdin_error.contains("supervised execution channel"),
904 "{stdin_error}"
905 );
906 let file_error = execute_with_file(&runtime, "printf bypass", 1, None).unwrap_err();
907 assert!(
908 file_error.contains("supervised execution channel"),
909 "{file_error}"
910 );
911 let source = tempfile::Builder::new().suffix(".rs").tempfile().unwrap();
912 std::fs::write(source.path(), "fn main() {}\n").unwrap();
913 let rust_error = execute_rust(source.path(), 1, None).unwrap_err();
914 assert!(
915 rust_error.contains("supervised execution channel"),
916 "{rust_error}"
917 );
918 assert!(!source.path().with_extension("").exists());
919 }
920
921 #[test]
922 fn execute_python_hello() {
923 let _lock = crate::core::data_dir::test_env_lock();
924 if !python_available() {
925 return;
926 }
927 let result = execute("python", "print('hello sandbox')", None);
928 assert_eq!(result.exit_code, 0);
929 assert!(result.stdout.contains("hello sandbox"));
930 }
931
932 #[test]
937 #[cfg(not(target_os = "windows"))]
938 fn execute_in_resolves_relative_paths_against_the_given_directory() {
939 let _lock = crate::core::data_dir::test_env_lock();
940 let dir = tempfile::tempdir().expect("tempdir");
941 std::fs::write(dir.path().join("only-here.txt"), "x").expect("write marker");
942
943 let scoped = execute_in("shell", "cat only-here.txt", None, Some(dir.path()));
944 assert_eq!(scoped.exit_code, 0, "stderr: {}", scoped.stderr);
945 assert!(scoped.stdout.contains('x'), "{scoped:?}");
946
947 let unscoped = execute_in("shell", "cat only-here.txt", None, None);
950 assert_ne!(
951 unscoped.exit_code, 0,
952 "precondition: the marker is only reachable from the given directory"
953 );
954 }
955
956 #[test]
959 #[cfg(not(target_os = "windows"))]
960 fn execute_in_reports_a_missing_directory_instead_of_falling_back() {
961 let _lock = crate::core::data_dir::test_env_lock();
962 let dir = tempfile::tempdir().expect("tempdir");
963 let missing = dir.path().join("does-not-exist");
964
965 let result = execute_in("shell", "pwd", None, Some(&missing));
966 assert_ne!(result.exit_code, 0, "{result:?}");
967 assert!(
968 result.stderr.contains("working directory does not exist"),
969 "{result:?}"
970 );
971 }
972
973 #[test]
974 #[cfg(not(target_os = "windows"))]
975 fn execute_shell_echo() {
976 let _lock = crate::core::data_dir::test_env_lock();
977 let result = execute("shell", "echo 'test output'", None);
978 assert_eq!(result.exit_code, 0);
979 assert!(result.stdout.contains("test output"));
980 }
981
982 #[test]
985 #[cfg(unix)]
986 fn a_run_ends_what_its_snippet_left_running() {
987 crate::shell::containment::tests::run_external_fixture(
988 module_path!(),
989 "left_running_fixture",
990 );
991 }
992
993 #[test]
998 #[ignore = "fixture body of a_run_ends_what_its_snippet_left_running"]
999 #[cfg(unix)]
1000 fn left_running_fixture() {
1001 let fixture = crate::shell::containment::tests::external_fixture();
1002 let _lock = crate::core::data_dir::test_env_lock();
1003 let result = execute(
1004 "shell",
1005 "sleep 20 >/dev/null 2>&1 &\necho \"LEFT $!\"",
1006 Some(20),
1007 );
1008 assert_eq!(result.exit_code, 0, "{}", result.stderr);
1009 let pid = crate::ipc::process::parse_pid_file(
1010 result
1011 .stdout
1012 .lines()
1013 .find_map(|line| line.strip_prefix("LEFT "))
1014 .expect("the snippet names its background child"),
1015 )
1016 .expect("the snippet printed a valid PID");
1017 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
1020 while crate::ipc::process::is_alive(pid) {
1022 assert!(
1023 std::time::Instant::now() < deadline,
1024 "a background child of the snippet outlived the run"
1025 );
1026 std::thread::sleep(std::time::Duration::from_millis(20));
1027 }
1028 fixture.complete();
1029 }
1030
1031 #[test]
1035 #[cfg(unix)]
1036 fn a_run_never_starts_before_its_guard_is_ready() {
1037 crate::shell::containment::tests::run_external_fixture(
1038 module_path!(),
1039 "run_caller_killed_fixture",
1040 );
1041 }
1042
1043 #[test]
1046 #[ignore = "fixture body of a_run_never_starts_before_its_guard_is_ready"]
1047 #[cfg(unix)]
1048 fn run_caller_killed_fixture() {
1049 let fixture = crate::shell::containment::tests::external_fixture();
1050 crate::shell::containment::tests::caller_killed_before_its_guard(
1051 module_path!(),
1052 "run_caller_standin",
1053 );
1054 fixture.complete();
1055 }
1056
1057 #[test]
1059 #[ignore = "stand-in caller of run_caller_killed_fixture"]
1060 #[cfg(unix)]
1061 fn run_caller_standin() {
1062 use std::os::unix::process::CommandExt as _;
1063 let marker = crate::shell::containment::tests::caller_standin_marker();
1064 let mut command = std::process::Command::new("sh");
1065 command
1066 .args([
1067 "-c",
1068 &format!("echo ran > '{}'; sleep 60", marker.display()),
1069 ])
1070 .stdin(std::process::Stdio::null())
1071 .stdout(std::process::Stdio::piped())
1072 .stderr(std::process::Stdio::piped())
1073 .process_group(0);
1074 let _ = super::wait_with_timeout(&mut command, 60, |error| error.to_string());
1075 }
1076
1077 #[test]
1078 fn execute_unsupported_language() {
1079 let result = execute("brainfuck", "++++", None);
1080 assert_eq!(result.exit_code, 1);
1081 assert!(result.stderr.contains("Unsupported language"));
1082 }
1083
1084 #[test]
1085 fn execute_rejects_oversized_code() {
1086 let huge = "a".repeat(MAX_CODE_BYTES + 1);
1087 let result = execute("python", &huge, None);
1088 assert_eq!(result.exit_code, 1);
1089 assert!(result.stderr.contains("exceeds the"));
1090 }
1091
1092 #[cfg(windows)]
1093 #[test]
1094 fn sandbox_env_preserves_system_drive() {
1095 let expected =
1096 std::env::var_os("SystemDrive").expect("SystemDrive must be defined on Windows");
1097
1098 let runtime = RuntimeConfig {
1099 command: "cmd".to_string(),
1100 args: Vec::new(),
1101 needs_temp_file: false,
1102 file_extension: "bat".to_string(),
1103 env: HashMap::new(),
1104 };
1105
1106 let mut cmd = Command::new("cmd");
1107 apply_sandbox_env(&mut cmd, &runtime);
1108
1109 let preserved = cmd.get_envs().any(|(key, value)| {
1110 key.to_string_lossy().eq_ignore_ascii_case("SystemDrive")
1111 && value == Some(expected.as_os_str())
1112 });
1113
1114 assert!(
1115 preserved,
1116 "Windows sandbox child environment must preserve SystemDrive"
1117 );
1118 }
1119
1120 #[test]
1121 fn execute_python_error() {
1122 let _lock = crate::core::data_dir::test_env_lock();
1123 if !python_available() {
1124 return;
1125 }
1126 let result = execute("python", "raise ValueError('test error')", None);
1127 assert_ne!(result.exit_code, 0);
1128 assert!(result.stderr.contains("ValueError"));
1129 }
1130
1131 #[test]
1132 fn execute_with_timeout() {
1133 let _lock = crate::core::data_dir::test_env_lock();
1134 if !python_available() {
1135 return;
1136 }
1137 let result = execute("python", "import time; time.sleep(60)", Some(1));
1138 assert_ne!(result.exit_code, 0);
1139 assert!(
1140 result.stderr.contains("timed out"),
1141 "stderr should mention timeout: {}",
1142 result.stderr
1143 );
1144 }
1145
1146 #[test]
1148 fn timeout_preserves_partial_output() {
1149 let _lock = crate::core::data_dir::test_env_lock();
1150 if !python_available() {
1151 return;
1152 }
1153 let result = execute(
1154 "python",
1155 "import sys; sys.stdout.write('PARTIAL_1504'); sys.stdout.flush(); import time; time.sleep(60)",
1156 Some(2),
1157 );
1158 assert_ne!(result.exit_code, 0);
1159 assert!(
1160 result.stdout.contains("PARTIAL_1504"),
1161 "partial stdout before timeout must be preserved, got: {:?}",
1162 result.stdout
1163 );
1164 assert!(
1165 result.stderr.contains("timed out"),
1166 "stderr should mention timeout: {}",
1167 result.stderr
1168 );
1169 }
1170
1171 #[test]
1172 fn truncate_preserves_head_and_tail() {
1173 let lines: Vec<String> = (0..100)
1174 .map(|i| format!("line {i}: some content here"))
1175 .collect();
1176 let output = lines.join("\n");
1177 let truncated = truncate_smart(&output, 500);
1178 assert!(truncated.contains("line 0:"));
1179 assert!(truncated.contains("line 99:"));
1180 assert!(truncated.contains("truncated"));
1181 }
1182
1183 #[test]
1184 fn supported_languages_list() {
1185 let langs = supported_languages();
1186 assert!(langs.contains(&"python"));
1187 assert!(langs.contains(&"javascript"));
1188 assert!(langs.contains(&"rust"));
1189 assert_eq!(langs.len(), 11);
1190 }
1191
1192 #[test]
1193 fn sandbox_env_is_set() {
1194 let _lock = crate::core::data_dir::test_env_lock();
1195 if !python_available() {
1196 return;
1197 }
1198 let result = execute(
1199 "python",
1200 "import os; print(os.environ.get('LEAN_CTX_SANDBOX', 'missing'))",
1201 None,
1202 );
1203 assert_eq!(result.exit_code, 0);
1204 assert!(result.stdout.contains('1'));
1205 }
1206
1207 #[test]
1208 #[cfg(not(target_os = "windows"))]
1209 fn batch_execute_multiple() {
1210 let _lock = crate::core::data_dir::test_env_lock();
1211 let items = vec![
1212 ("python".to_string(), "print(1+1)".to_string()),
1213 ("shell".to_string(), "echo hello".to_string()),
1214 ];
1215 let results = batch_execute(&items);
1216 assert_eq!(results.len(), 2);
1217 assert!(results[0].stdout.contains('2'));
1218 assert!(results[1].stdout.contains("hello"));
1219 }
1220}