Skip to main content

lean_ctx/core/
sandbox.rs

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;
17/// Upper bound on the `code` payload. Generous for real scripts while preventing an
18/// agent from forcing a multi-megabyte temp-file write / interpreter argv → memory abuse.
19const MAX_CODE_BYTES: usize = 256 * 1024;
20
21/// Runs a snippet in the sandbox runtime of `language`.
22///
23/// A run does not leave processes behind (Q13, Unix, in the `lean-ctx`
24/// binary): whatever the snippet started in its process group is ended when
25/// the run is over — after a normal exit before the output is collected, on a
26/// timeout with the snippet itself — and when this process is gone.
27pub fn execute(language: &str, code: &str, timeout_secs: Option<u64>) -> SandboxResult {
28    execute_in(language, code, timeout_secs, None)
29}
30
31/// Same as [`execute`], but runs the snippet in `cwd` (GH #1666).
32///
33/// A caller that already resolved a working directory — `ctx_shell` rerouting
34/// an interpreter heredoc, for one — must be able to hand it over. Without
35/// this, the snippet inherited the server process's directory while the rest
36/// of the same tool call ran in `cwd`, so relative paths in the snippet
37/// resolved against a different tree and quietly hit the wrong files.
38pub 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        // An explicitly requested OS sandbox that this platform cannot provide
120        // must not silently degrade to unsandboxed execution
121        // (GHSA-rxv5-4c3x-g4q6 B).
122        #[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
158/// Same as [`batch_execute`], but every task runs in `cwd`.
159pub 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/// Whether `language` resolves to the shell runtime. Every alias must take
170/// the `ctx_shell` allowlist preflight (GHSA-rxv5-4c3x-g4q6 B), so callers
171/// use this instead of comparing against `"shell"`.
172#[must_use]
173pub fn is_shell_language(language: &str) -> bool {
174    matches!(language.to_lowercase().as_str(), "shell" | "bash" | "sh")
175}
176
177/// Refusal for `sandbox_level >= 1` on a platform without an OS sandbox.
178#[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
200/// The language `language` runs as (one of [`supported_languages`]), after
201/// aliases (`py`, `node`, `bash`, …) and case; `None` when unsupported.
202pub 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        // The profile denies by default, so a working directory the caller
348        // chose has to be granted explicitly — otherwise the snippet starts
349        // there and cannot read a thing (#1666). Write permission is
350        // deliberately not granted: that boundary belongs to sandbox_level.
351        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
498/// Run the child in `cwd` when the caller resolved one (GH #1666).
499///
500/// A directory that no longer exists would make `spawn` fail with a message
501/// about the *command*, which reads as a missing interpreter. Falling back to
502/// the inherited directory is not an option either — running somewhere other
503/// than the caller asked, without saying so, is the bug this exists to fix —
504/// so an unusable directory is reported as itself.
505fn 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
533/// All level-zero interpreter, compiler and compiled-program launches pass
534/// here. A protected MCP process must delegate even when optional sandboxing
535/// is disabled; it must never spawn a child with access to its context store.
536fn 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    // GH #1347: isolate child into its own process group so an interactive
580    // shell (bash -ic) cannot SIGTSTP the MCP server, and close stdin to
581    // prevent terminal job-control attempts.
582    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            // Preserve argv[0] for shims such as rustup's rustc symlink.
631            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/// Lets go of the run's process guard: it ends what is left of the command's
712/// process group and is reaped.
713#[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    // #1920: contain the whole tree, so a timeout on Windows ends what the
729    // snippet started instead of only the interpreter.
730    crate::shell::process_tree::ProcessTree::prepare(cmd);
731    // Q13 (Unix): the command leads its own process group (`process_group(0)`
732    // in the only caller) and has a guard on that group, which ends it when
733    // this run is over or this process is gone. The command's program is
734    // executed only once the guard is ready: a caller that dies in between
735    // leaves nothing running, and a run that cannot get a guard never
736    // started. `None` for the guard: this process is not the `lean-ctx`
737    // binary.
738    #[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                // What the command left running in its group is ended before
765                // the output is collected, so a leftover that holds the pipes
766                // cannot keep this call waiting.
767                #[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                    // GH #1504: after killing, drain whatever the child wrote
777                    // before the timeout so partial output is preserved instead
778                    // of being silently discarded.
779                    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    /// GH #1666: a snippet must run where the caller said, because relative
933    /// paths inside it are resolved there. The `cwd: None` half of this test is
934    /// the pre-fix behaviour — it is what made a script edit the project root
935    /// while the caller was working in a worktree, and report success.
936    #[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        // Without a cwd the same relative path resolves somewhere else, which
948        // is exactly what the reported bug did silently.
949        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    /// A directory that cannot be entered is reported as itself. Falling back
957    /// to the inherited directory would reinstate the silent wrong-tree run.
958    #[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    /// Q13: a sandbox run does not leave processes behind. A background
983    /// child of the snippet is ended before the output is returned.
984    #[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    /// Body of the test above; runs in a fixture process group of its own
994    /// that is ended when this returns or an assertion fails. The snippet's
995    /// background child is in the snippet's group, not the fixture's: if a
996    /// regression leaves it, it expires by itself (20 s).
997    #[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        // The guard has finished before `execute` returns; what remains is the
1018        // reap of the killed child by the system.
1019        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
1020        // Validated probe: a 0 or wrapped PID is never handed to kill(2).
1021        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    /// Q13: a sandbox run's command is not executed before its guard is
1032    /// ready. A caller killed in between leaves nothing running, and the
1033    /// command's program never ran.
1034    #[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    /// Body of the test above: the stand-in below is killed between "the
1044    /// command's process exists" and "attach its guard".
1045    #[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    /// Stands in for a process in the middle of a sandbox run.
1058    #[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    /// GH #1504: partial stdout emitted before a timeout must be preserved.
1147    #[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}