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    items
156        .iter()
157        .map(|(lang, code)| execute(lang, code, None))
158        .collect()
159}
160
161/// Whether `language` resolves to the shell runtime. Every alias must take
162/// the `ctx_shell` allowlist preflight (GHSA-rxv5-4c3x-g4q6 B), so callers
163/// use this instead of comparing against `"shell"`.
164#[must_use]
165pub fn is_shell_language(language: &str) -> bool {
166    matches!(language.to_lowercase().as_str(), "shell" | "bash" | "sh")
167}
168
169/// Refusal for `sandbox_level >= 1` on a platform without an OS sandbox.
170#[cfg_attr(any(target_os = "macos", target_os = "linux"), allow(dead_code))]
171fn sandbox_unavailable(language: &str, start: std::time::Instant) -> SandboxResult {
172    SandboxResult {
173        stdout: String::new(),
174        stderr: "sandbox_level >= 1 is configured but no OS sandbox is available on this \
175                 platform; refusing to run the code unsandboxed. Set sandbox_level = 0 to \
176                 accept unsandboxed execution."
177            .to_string(),
178        exit_code: 1,
179        language: language.to_string(),
180        duration_ms: start.elapsed().as_millis() as u64,
181    }
182}
183
184struct RuntimeConfig {
185    command: String,
186    args: Vec<String>,
187    needs_temp_file: bool,
188    file_extension: String,
189    env: HashMap<String, String>,
190}
191
192/// The language `language` runs as (one of [`supported_languages`]), after
193/// aliases (`py`, `node`, `bash`, …) and case; `None` when unsupported.
194pub fn canonical_language(language: &str) -> Option<&'static str> {
195    Some(match language.to_lowercase().as_str() {
196        "javascript" | "js" | "node" => "javascript",
197        "typescript" | "ts" => "typescript",
198        "python" | "py" => "python",
199        "shell" | "bash" | "sh" => "shell",
200        "ruby" | "rb" => "ruby",
201        "go" | "golang" => "go",
202        "rust" | "rs" => "rust",
203        "php" => "php",
204        "perl" | "pl" => "perl",
205        "r" => "r",
206        "elixir" | "ex" => "elixir",
207        _ => return None,
208    })
209}
210
211fn resolve_runtime(language: &str) -> Option<RuntimeConfig> {
212    match canonical_language(language)? {
213        "javascript" => Some(RuntimeConfig {
214            command: find_binary(&["bun", "node"])?,
215            args: vec!["-e".to_string()],
216            needs_temp_file: false,
217            file_extension: "js".to_string(),
218            env: HashMap::new(),
219        }),
220        "typescript" => Some(RuntimeConfig {
221            command: find_binary(&["bun", "npx"])?,
222            args: if which_exists("bun") {
223                vec!["-e".to_string()]
224            } else {
225                vec!["tsx".to_string(), "-e".to_string()]
226            },
227            needs_temp_file: false,
228            file_extension: "ts".to_string(),
229            env: HashMap::new(),
230        }),
231        "python" => Some(RuntimeConfig {
232            command: find_binary(&["python3", "python"])?,
233            args: vec!["-c".to_string()],
234            needs_temp_file: false,
235            file_extension: "py".to_string(),
236            env: HashMap::from([("PYTHONDONTWRITEBYTECODE".into(), "1".into())]),
237        }),
238        lang if is_shell_language(lang) => {
239            #[cfg(target_os = "windows")]
240            {
241                Some(RuntimeConfig {
242                    command: "cmd".to_string(),
243                    args: vec!["/C".to_string()],
244                    needs_temp_file: false,
245                    file_extension: "bat".to_string(),
246                    env: HashMap::new(),
247                })
248            }
249            #[cfg(not(target_os = "windows"))]
250            {
251                Some(RuntimeConfig {
252                    command: find_binary(&["bash", "sh"])?,
253                    args: vec!["-c".to_string()],
254                    needs_temp_file: false,
255                    file_extension: "sh".to_string(),
256                    env: HashMap::new(),
257                })
258            }
259        }
260        "ruby" => Some(RuntimeConfig {
261            command: find_binary(&["ruby"])?,
262            args: vec!["-e".to_string()],
263            needs_temp_file: false,
264            file_extension: "rb".to_string(),
265            env: HashMap::new(),
266        }),
267        "go" => Some(RuntimeConfig {
268            command: find_binary(&["go"])?,
269            args: vec!["run".to_string()],
270            needs_temp_file: true,
271            file_extension: "go".to_string(),
272            env: HashMap::new(),
273        }),
274        "rust" => Some(RuntimeConfig {
275            command: "rustc_script".to_string(),
276            args: vec![],
277            needs_temp_file: true,
278            file_extension: "rs".to_string(),
279            env: HashMap::new(),
280        }),
281        "php" => Some(RuntimeConfig {
282            command: find_binary(&["php"])?,
283            args: vec!["-r".to_string()],
284            needs_temp_file: false,
285            file_extension: "php".to_string(),
286            env: HashMap::new(),
287        }),
288        "perl" => Some(RuntimeConfig {
289            command: find_binary(&["perl"])?,
290            args: vec!["-e".to_string()],
291            needs_temp_file: false,
292            file_extension: "pl".to_string(),
293            env: HashMap::new(),
294        }),
295        "r" => Some(RuntimeConfig {
296            command: find_binary(&["Rscript"])?,
297            args: vec!["-e".to_string()],
298            needs_temp_file: false,
299            file_extension: "R".to_string(),
300            env: HashMap::new(),
301        }),
302        "elixir" => Some(RuntimeConfig {
303            command: find_binary(&["elixir"])?,
304            args: vec!["-e".to_string()],
305            needs_temp_file: false,
306            file_extension: "exs".to_string(),
307            env: HashMap::new(),
308        }),
309        _ => None,
310    }
311}
312
313fn seatbelt_execute(
314    runtime: &RuntimeConfig,
315    code: &str,
316    timeout: u64,
317    cwd: Option<&std::path::Path>,
318) -> Result<(String, String, i32), String> {
319    let tmp_dir = std::env::temp_dir().join("lean-ctx-sandbox");
320    let _ = std::fs::create_dir_all(&tmp_dir);
321
322    let env_pairs: Vec<(String, String)> = runtime
323        .env
324        .iter()
325        .map(|(k, v)| (k.clone(), v.clone()))
326        .collect();
327
328    if runtime.needs_temp_file {
329        let suffix = format!(".{}", runtime.file_extension);
330        let tmp = tempfile::Builder::new()
331            .prefix("exec_")
332            .suffix(&suffix)
333            .tempfile_in(&tmp_dir)
334            .map_err(|e| format!("Failed to create temp file: {e}"))?;
335        let file_path = tmp.into_temp_path();
336        std::fs::write(&file_path, code).map_err(|e| format!("Failed to write temp file: {e}"))?;
337
338        let mut allowed = vec![file_path.to_path_buf()];
339        // The profile denies by default, so a working directory the caller
340        // chose has to be granted explicitly — otherwise the snippet starts
341        // there and cannot read a thing (#1666). Write permission is
342        // deliberately not granted: that boundary belongs to sandbox_level.
343        if let Some(dir) = cwd {
344            allowed.push(dir.to_path_buf());
345        }
346        let allowed_refs: Vec<&std::path::Path> =
347            allowed.iter().map(std::path::PathBuf::as_path).collect();
348        let file_str = file_path.to_string_lossy().to_string();
349
350        let mut args: Vec<&str> = runtime
351            .args
352            .iter()
353            .map(std::string::String::as_str)
354            .collect();
355        args.push(&file_str);
356
357        let result = super::sandbox_seatbelt::execute_sandboxed(
358            &runtime.command,
359            &args,
360            &allowed_refs,
361            &env_pairs,
362            timeout,
363            cwd,
364        );
365        let _ = std::fs::remove_file(&file_path);
366        result
367    } else {
368        let mut args: Vec<&str> = runtime
369            .args
370            .iter()
371            .map(std::string::String::as_str)
372            .collect();
373        args.push(code);
374        let allowed: Vec<std::path::PathBuf> =
375            cwd.map(|d| vec![d.to_path_buf()]).unwrap_or_default();
376        let allowed_refs: Vec<&std::path::Path> =
377            allowed.iter().map(std::path::PathBuf::as_path).collect();
378        super::sandbox_seatbelt::execute_sandboxed(
379            &runtime.command,
380            &args,
381            &allowed_refs,
382            &env_pairs,
383            timeout,
384            cwd,
385        )
386    }
387}
388
389#[cfg(target_os = "linux")]
390fn landlock_execute(
391    runtime: &RuntimeConfig,
392    code: &str,
393    timeout: u64,
394    cwd: Option<&std::path::Path>,
395) -> Result<(String, String, i32), String> {
396    let tmp_dir = std::env::temp_dir().join("lean-ctx-sandbox");
397    let _ = std::fs::create_dir_all(&tmp_dir);
398
399    let env_pairs: Vec<(String, String)> = runtime
400        .env
401        .iter()
402        .map(|(k, v)| (k.clone(), v.clone()))
403        .collect();
404
405    if runtime.needs_temp_file {
406        let suffix = format!(".{}", runtime.file_extension);
407        let tmp = tempfile::Builder::new()
408            .prefix("exec_")
409            .suffix(&suffix)
410            .tempfile_in(&tmp_dir)
411            .map_err(|e| format!("Failed to create temp file: {e}"))?;
412        let file_path = tmp.into_temp_path();
413        std::fs::write(&file_path, code).map_err(|e| format!("Failed to write temp file: {e}"))?;
414
415        let mut allowed = vec![file_path.to_path_buf()];
416        if let Some(dir) = cwd {
417            allowed.push(dir.to_path_buf());
418        }
419        let allowed_refs: Vec<&std::path::Path> =
420            allowed.iter().map(std::path::PathBuf::as_path).collect();
421        let file_str = file_path.to_string_lossy().to_string();
422
423        let mut args: Vec<&str> = runtime
424            .args
425            .iter()
426            .map(std::string::String::as_str)
427            .collect();
428        args.push(&file_str);
429
430        let result = super::sandbox_landlock::execute_sandboxed(
431            &runtime.command,
432            &args,
433            &allowed_refs,
434            &env_pairs,
435            timeout,
436            cwd,
437        );
438        let _ = std::fs::remove_file(&file_path);
439        result
440    } else {
441        let mut args: Vec<&str> = runtime
442            .args
443            .iter()
444            .map(std::string::String::as_str)
445            .collect();
446        args.push(code);
447        let allowed: Vec<std::path::PathBuf> =
448            cwd.map(|d| vec![d.to_path_buf()]).unwrap_or_default();
449        let allowed_refs: Vec<&std::path::Path> =
450            allowed.iter().map(std::path::PathBuf::as_path).collect();
451        super::sandbox_landlock::execute_sandboxed(
452            &runtime.command,
453            &args,
454            &allowed_refs,
455            &env_pairs,
456            timeout,
457            cwd,
458        )
459    }
460}
461
462const SANDBOX_ENV_ALLOWLIST: &[&str] = &[
463    "PATH",
464    "HOME",
465    "USER",
466    "LANG",
467    "LC_ALL",
468    "TERM",
469    "TMPDIR",
470    "TMP",
471    "TEMP",
472    "SYSTEMROOT",
473    "WINDIR",
474    "SystemDrive",
475];
476
477fn apply_sandbox_env(cmd: &mut Command, runtime: &RuntimeConfig) {
478    cmd.env_clear();
479    for key in SANDBOX_ENV_ALLOWLIST {
480        if let Ok(val) = std::env::var(key) {
481            cmd.env(key, val);
482        }
483    }
484    for (k, v) in &runtime.env {
485        cmd.env(k, v);
486    }
487    cmd.env("LEAN_CTX_SANDBOX", "1");
488}
489
490/// Run the child in `cwd` when the caller resolved one (GH #1666).
491///
492/// A directory that no longer exists would make `spawn` fail with a message
493/// about the *command*, which reads as a missing interpreter. Falling back to
494/// the inherited directory is not an option either — running somewhere other
495/// than the caller asked, without saying so, is the bug this exists to fix —
496/// so an unusable directory is reported as itself.
497fn apply_cwd(cmd: &mut Command, cwd: Option<&std::path::Path>) -> Result<(), String> {
498    let Some(dir) = cwd else { return Ok(()) };
499    if !dir.is_dir() {
500        return Err(format!(
501            "working directory does not exist: {}",
502            dir.display()
503        ));
504    }
505    cmd.current_dir(dir);
506    Ok(())
507}
508
509fn execute_with_stdin(
510    runtime: &RuntimeConfig,
511    code: &str,
512    timeout: u64,
513    cwd: Option<&std::path::Path>,
514) -> Result<(String, String, i32), String> {
515    let mut cmd = Command::new(&runtime.command);
516    apply_cwd(&mut cmd, cwd)?;
517    for arg in &runtime.args {
518        cmd.arg(arg);
519    }
520    cmd.arg(code);
521    apply_sandbox_env(&mut cmd, runtime);
522    run_runtime_command(cmd, timeout)
523}
524
525/// All level-zero interpreter, compiler and compiled-program launches pass
526/// here. A protected MCP process must delegate even when optional sandboxing
527/// is disabled; it must never spawn a child with access to its context store.
528fn run_runtime_command(mut cmd: Command, timeout: u64) -> Result<(String, String, i32), String> {
529    #[cfg(unix)]
530    {
531        use crate::core::protected_execution::{Mode, launch_mode};
532        match launch_mode()? {
533            Mode::Brokered(client) => {
534                let executable = resolve_program_path(&cmd)?;
535                let executable = executable.to_str().ok_or("runtime path is not UTF-8")?;
536                let args: Vec<&str> = cmd
537                    .get_args()
538                    .map(|arg| arg.to_str().ok_or("runtime argument is not UTF-8"))
539                    .collect::<Result<_, _>>()?;
540                let env: Vec<(String, String)> = cmd
541                    .get_envs()
542                    .filter_map(|(key, value)| value.map(|value| (key, value)))
543                    .map(|(key, value)| {
544                        Ok((
545                            key.to_str()
546                                .ok_or("runtime environment key is not UTF-8")?
547                                .to_owned(),
548                            value
549                                .to_str()
550                                .ok_or("runtime environment value is not UTF-8")?
551                                .to_owned(),
552                        ))
553                    })
554                    .collect::<Result<_, String>>()?;
555                return client.session_program(
556                    executable,
557                    &args,
558                    &env,
559                    timeout,
560                    cmd.get_current_dir(),
561                );
562            }
563            Mode::Sandboxed(_) => {
564                return Err("runtime execution must use the supervised program channel".into());
565            }
566            Mode::Direct => {}
567        }
568    }
569    #[cfg(not(unix))]
570    let _protected = crate::core::protected_store::protected_child_prefix()?;
571    // GH #1347: isolate child into its own process group so an interactive
572    // shell (bash -ic) cannot SIGTSTP the MCP server, and close stdin to
573    // prevent terminal job-control attempts.
574    cmd.stdin(std::process::Stdio::null());
575    cmd.stdout(std::process::Stdio::piped());
576    cmd.stderr(std::process::Stdio::piped());
577    #[cfg(unix)]
578    cmd.process_group(0);
579
580    let program = cmd.get_program().to_string_lossy().into_owned();
581    let output = wait_with_timeout(&mut cmd, timeout, |e| {
582        format!("Failed to spawn {program}: {e}")
583    })?;
584    Ok((
585        crate::shell::decode_output(&output.stdout),
586        crate::shell::decode_output(&output.stderr),
587        crate::shell::exit_status::exit_code(output.status),
588    ))
589}
590
591#[cfg(unix)]
592fn resolve_program_path(cmd: &Command) -> Result<std::path::PathBuf, String> {
593    use std::os::unix::fs::PermissionsExt;
594    let program = std::path::Path::new(cmd.get_program());
595    if program.is_absolute() {
596        return Ok(program.to_path_buf());
597    }
598    let cwd = cmd
599        .get_current_dir()
600        .map(std::path::Path::to_path_buf)
601        .unwrap_or(std::env::current_dir().map_err(|_| "runtime working directory unavailable")?);
602    if program.components().count() > 1 {
603        return Ok(cwd.join(program));
604    }
605    let path = cmd
606        .get_envs()
607        .find(|(key, _)| *key == "PATH")
608        .and_then(|(_, value)| value.map(std::ffi::OsStr::to_os_string))
609        .or_else(|| std::env::var_os("PATH"))
610        .ok_or("runtime PATH unavailable")?;
611    for directory in std::env::split_paths(&path) {
612        let directory = if directory.is_absolute() {
613            directory
614        } else {
615            cwd.join(directory)
616        };
617        let candidate = directory.join(program);
618        if candidate
619            .metadata()
620            .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
621        {
622            // Preserve argv[0] for shims such as rustup's rustc symlink.
623            return Ok(candidate);
624        }
625    }
626    Err("runtime executable unavailable".into())
627}
628
629fn execute_with_file(
630    runtime: &RuntimeConfig,
631    code: &str,
632    timeout: u64,
633    cwd: Option<&std::path::Path>,
634) -> Result<(String, String, i32), String> {
635    let tmp_dir = std::env::temp_dir().join("lean-ctx-sandbox");
636    let _ = std::fs::create_dir_all(&tmp_dir);
637
638    let suffix = format!(".{}", runtime.file_extension);
639    let tmp = tempfile::Builder::new()
640        .prefix("exec_")
641        .suffix(&suffix)
642        .tempfile_in(&tmp_dir)
643        .map_err(|e| format!("Failed to create temp file: {e}"))?;
644    let file_path = tmp.into_temp_path();
645
646    std::fs::write(&file_path, code).map_err(|e| format!("Failed to write temp file: {e}"))?;
647
648    let result = if runtime.command == "rustc_script" {
649        execute_rust(&file_path, timeout, cwd)
650    } else {
651        let mut cmd = Command::new(&runtime.command);
652        apply_cwd(&mut cmd, cwd)?;
653        for arg in &runtime.args {
654            cmd.arg(arg);
655        }
656        cmd.arg(&file_path);
657        apply_sandbox_env(&mut cmd, runtime);
658        run_runtime_command(cmd, timeout)
659    };
660
661    let _ = std::fs::remove_file(&file_path);
662    result
663}
664
665fn execute_rust(
666    source_path: &std::path::Path,
667    timeout: u64,
668    cwd: Option<&std::path::Path>,
669) -> Result<(String, String, i32), String> {
670    let binary_path = source_path.with_extension("");
671
672    let mut compile_cmd = Command::new("rustc");
673    compile_cmd.arg(source_path).arg("-o").arg(&binary_path);
674    compile_cmd.env_clear();
675    for key in SANDBOX_ENV_ALLOWLIST {
676        if let Ok(val) = std::env::var(key) {
677            compile_cmd.env(key, val);
678        }
679    }
680    compile_cmd.env("LEAN_CTX_SANDBOX", "1");
681
682    let (_, stderr, code) = run_runtime_command(compile_cmd, timeout)?;
683
684    if code != 0 {
685        let _ = std::fs::remove_file(&binary_path);
686        return Ok((String::new(), stderr, code));
687    }
688
689    let mut run_cmd = Command::new(&binary_path);
690    apply_cwd(&mut run_cmd, cwd)?;
691    run_cmd.env_clear();
692    for key in SANDBOX_ENV_ALLOWLIST {
693        if let Ok(val) = std::env::var(key) {
694            run_cmd.env(key, val);
695        }
696    }
697    run_cmd.env("LEAN_CTX_SANDBOX", "1");
698    let result = run_runtime_command(run_cmd, timeout);
699    let _ = std::fs::remove_file(&binary_path);
700    result
701}
702
703/// Lets go of the run's process guard: it ends what is left of the command's
704/// process group and is reaped.
705#[cfg(unix)]
706fn let_go_of_guard(guard: &mut Option<crate::shell::containment::CallGuard>) {
707    if let Some(guard) = guard.take() {
708        let ended = guard.finish();
709        if ended != crate::shell::containment::GuardOutcome::Ended {
710            tracing::warn!("sandbox run: the end of its process group is {ended:?}");
711        }
712    }
713}
714
715fn wait_with_timeout(
716    cmd: &mut Command,
717    timeout_secs: u64,
718    spawn_error: impl FnOnce(std::io::Error) -> String,
719) -> Result<std::process::Output, String> {
720    // #1920: contain the whole tree, so a timeout on Windows ends what the
721    // snippet started instead of only the interpreter.
722    crate::shell::process_tree::ProcessTree::prepare(cmd);
723    // Q13 (Unix): the command leads its own process group (`process_group(0)`
724    // in the only caller) and has a guard on that group, which ends it when
725    // this run is over or this process is gone. The command's program is
726    // executed only once the guard is ready: a caller that dies in between
727    // leaves nothing running, and a run that cannot get a guard never
728    // started. `None` for the guard: this process is not the `lean-ctx`
729    // binary.
730    #[cfg(unix)]
731    let (mut child, mut guard) = {
732        use crate::shell::containment::Refusal;
733        match crate::shell::containment::start_guarded_child(cmd) {
734            Ok(started) => started,
735            Err(Refusal::NotStarted(error)) => return Err(spawn_error(error)),
736            Err(Refusal::NotLeader) => {
737                return Err(
738                    "Execution could not be contained: the process does not lead a group of its own"
739                        .to_string(),
740                );
741            }
742            Err(Refusal::NoGuard(reason)) => {
743                return Err(format!("Execution could not be contained: {reason}"));
744            }
745        }
746    };
747    #[cfg(not(unix))]
748    let mut child = cmd.spawn().map_err(spawn_error)?;
749    let tree = crate::shell::process_tree::ProcessTree::start(&mut child)?;
750    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
751
752    loop {
753        match child.try_wait() {
754            Ok(Some(_)) => {
755                tree.release();
756                // What the command left running in its group is ended before
757                // the output is collected, so a leftover that holds the pipes
758                // cannot keep this call waiting.
759                #[cfg(unix)]
760                let_go_of_guard(&mut guard);
761                return child.wait_with_output().map_err(|e| e.to_string());
762            }
763            Ok(None) => {
764                if std::time::Instant::now() > deadline {
765                    tree.kill(&mut child);
766                    #[cfg(unix)]
767                    let_go_of_guard(&mut guard);
768                    // GH #1504: after killing, drain whatever the child wrote
769                    // before the timeout so partial output is preserved instead
770                    // of being silently discarded.
771                    if let Ok(mut output) = child.wait_with_output() {
772                        output.stderr.extend_from_slice(
773                            format!("\n[lean-ctx] Execution timed out after {timeout_secs}s")
774                                .as_bytes(),
775                        );
776                        return Ok(output);
777                    }
778                    return Err(format!("Execution timed out after {timeout_secs}s"));
779                }
780                std::thread::sleep(std::time::Duration::from_millis(50));
781            }
782            Err(e) => {
783                tree.kill(&mut child);
784                #[cfg(unix)]
785                let_go_of_guard(&mut guard);
786                let _ = child.wait();
787                return Err(e.to_string());
788            }
789        }
790    }
791}
792
793fn find_binary(candidates: &[&str]) -> Option<String> {
794    for name in candidates {
795        if which_exists(name) {
796            return Some(name.to_string());
797        }
798    }
799    None
800}
801
802fn which_exists(name: &str) -> bool {
803    #[cfg(target_os = "windows")]
804    let check_cmd = Command::new("where")
805        .arg(name)
806        .stdout(std::process::Stdio::null())
807        .stderr(std::process::Stdio::null())
808        .status();
809
810    #[cfg(not(target_os = "windows"))]
811    let check_cmd = Command::new("which")
812        .arg(name)
813        .stdout(std::process::Stdio::null())
814        .stderr(std::process::Stdio::null())
815        .status();
816
817    check_cmd.is_ok_and(|s| s.success())
818}
819
820fn truncate_output(output: &str) -> String {
821    if output.len() <= MAX_OUTPUT_BYTES {
822        return output.to_string();
823    }
824    crate::core::recovery::with_original_pointer(
825        "ctx_execute",
826        "stdout",
827        output,
828        truncate_smart(output, MAX_OUTPUT_BYTES),
829    )
830}
831
832fn truncate_smart(output: &str, max_bytes: usize) -> String {
833    if output.len() <= max_bytes {
834        return output.to_string();
835    }
836
837    let lines: Vec<&str> = output.lines().collect();
838    let total_lines = lines.len();
839
840    let head_count = (total_lines * 60) / 100;
841    let tail_count = total_lines - head_count;
842
843    let head: Vec<&str> = lines.iter().take(head_count).copied().collect();
844    let tail: Vec<&str> = lines
845        .iter()
846        .skip(total_lines - tail_count)
847        .copied()
848        .collect();
849
850    let head_text = head.join("\n");
851    let tail_text = tail.join("\n");
852
853    if head_text.len() + tail_text.len() + 100 > max_bytes {
854        let half = max_bytes / 2;
855        let h = &output[..output.floor_char_boundary(half.min(output.len()))];
856        let t_start = output.ceil_char_boundary(output.len().saturating_sub(half));
857        let t = &output[t_start..];
858        let skipped = output.len() - h.len() - t.len();
859        return format!("{h}\n\n... [{skipped} bytes truncated — showing head + tail] ...\n\n{t}");
860    }
861
862    let skipped_lines = total_lines - head_count - tail_count;
863    let skipped_bytes = output.len() - head_text.len() - tail_text.len();
864    format!(
865        "{head_text}\n\n... [{skipped_lines} lines / {skipped_bytes} bytes truncated — showing first {head_count} + last {tail_count} lines] ...\n\n{tail_text}"
866    )
867}
868
869pub fn supported_languages() -> &'static [&'static str] {
870    &[
871        "javascript",
872        "typescript",
873        "python",
874        "shell",
875        "ruby",
876        "go",
877        "rust",
878        "php",
879        "perl",
880        "r",
881        "elixir",
882    ]
883}
884
885#[cfg(test)]
886pub mod tests {
887    use super::*;
888
889    fn python_available() -> bool {
890        find_binary(&["python3", "python"]).is_some()
891    }
892
893    #[cfg(target_os = "macos")]
894    #[test]
895    fn level_zero_interpreters_and_compilers_require_the_protected_channel() {
896        let _lock = crate::core::data_dir::test_env_lock();
897        let isolation = crate::core::data_dir::isolated_data_dir();
898        let _session =
899            crate::core::protected_store::pin_synthetic_session(isolation.path()).unwrap();
900        let runtime = RuntimeConfig {
901            command: "/bin/sh".into(),
902            args: vec!["-c".into()],
903            needs_temp_file: false,
904            file_extension: "sh".into(),
905            env: HashMap::new(),
906        };
907        let stdin_error = execute_with_stdin(&runtime, "printf bypass", 1, None).unwrap_err();
908        assert!(
909            stdin_error.contains("supervised execution channel"),
910            "{stdin_error}"
911        );
912        let file_error = execute_with_file(&runtime, "printf bypass", 1, None).unwrap_err();
913        assert!(
914            file_error.contains("supervised execution channel"),
915            "{file_error}"
916        );
917        let source = tempfile::Builder::new().suffix(".rs").tempfile().unwrap();
918        std::fs::write(source.path(), "fn main() {}\n").unwrap();
919        let rust_error = execute_rust(source.path(), 1, None).unwrap_err();
920        assert!(
921            rust_error.contains("supervised execution channel"),
922            "{rust_error}"
923        );
924        assert!(!source.path().with_extension("").exists());
925    }
926
927    #[test]
928    fn execute_python_hello() {
929        let _lock = crate::core::data_dir::test_env_lock();
930        if !python_available() {
931            return;
932        }
933        let result = execute("python", "print('hello sandbox')", None);
934        assert_eq!(result.exit_code, 0);
935        assert!(result.stdout.contains("hello sandbox"));
936    }
937
938    /// GH #1666: a snippet must run where the caller said, because relative
939    /// paths inside it are resolved there. The `cwd: None` half of this test is
940    /// the pre-fix behaviour — it is what made a script edit the project root
941    /// while the caller was working in a worktree, and report success.
942    #[test]
943    #[cfg(not(target_os = "windows"))]
944    fn execute_in_resolves_relative_paths_against_the_given_directory() {
945        let _lock = crate::core::data_dir::test_env_lock();
946        let dir = tempfile::tempdir().expect("tempdir");
947        std::fs::write(dir.path().join("only-here.txt"), "x").expect("write marker");
948
949        let scoped = execute_in("shell", "cat only-here.txt", None, Some(dir.path()));
950        assert_eq!(scoped.exit_code, 0, "stderr: {}", scoped.stderr);
951        assert!(scoped.stdout.contains('x'), "{scoped:?}");
952
953        // Without a cwd the same relative path resolves somewhere else, which
954        // is exactly what the reported bug did silently.
955        let unscoped = execute_in("shell", "cat only-here.txt", None, None);
956        assert_ne!(
957            unscoped.exit_code, 0,
958            "precondition: the marker is only reachable from the given directory"
959        );
960    }
961
962    /// A directory that cannot be entered is reported as itself. Falling back
963    /// to the inherited directory would reinstate the silent wrong-tree run.
964    #[test]
965    #[cfg(not(target_os = "windows"))]
966    fn execute_in_reports_a_missing_directory_instead_of_falling_back() {
967        let _lock = crate::core::data_dir::test_env_lock();
968        let dir = tempfile::tempdir().expect("tempdir");
969        let missing = dir.path().join("does-not-exist");
970
971        let result = execute_in("shell", "pwd", None, Some(&missing));
972        assert_ne!(result.exit_code, 0, "{result:?}");
973        assert!(
974            result.stderr.contains("working directory does not exist"),
975            "{result:?}"
976        );
977    }
978
979    #[test]
980    #[cfg(not(target_os = "windows"))]
981    fn execute_shell_echo() {
982        let _lock = crate::core::data_dir::test_env_lock();
983        let result = execute("shell", "echo 'test output'", None);
984        assert_eq!(result.exit_code, 0);
985        assert!(result.stdout.contains("test output"));
986    }
987
988    /// Q13: a sandbox run does not leave processes behind. A background
989    /// child of the snippet is ended before the output is returned.
990    #[test]
991    #[cfg(unix)]
992    fn a_run_ends_what_its_snippet_left_running() {
993        crate::shell::containment::tests::run_external_fixture(
994            module_path!(),
995            "left_running_fixture",
996        );
997    }
998
999    /// Body of the test above; runs in a fixture process group of its own
1000    /// that is ended when this returns or an assertion fails. The snippet's
1001    /// background child is in the snippet's group, not the fixture's: if a
1002    /// regression leaves it, it expires by itself (20 s).
1003    #[test]
1004    #[ignore = "fixture body of a_run_ends_what_its_snippet_left_running"]
1005    #[cfg(unix)]
1006    fn left_running_fixture() {
1007        let fixture = crate::shell::containment::tests::external_fixture();
1008        let _lock = crate::core::data_dir::test_env_lock();
1009        let result = execute(
1010            "shell",
1011            "sleep 20 >/dev/null 2>&1 &\necho \"LEFT $!\"",
1012            Some(20),
1013        );
1014        assert_eq!(result.exit_code, 0, "{}", result.stderr);
1015        let pid: libc::pid_t = result
1016            .stdout
1017            .lines()
1018            .find_map(|line| line.strip_prefix("LEFT "))
1019            .expect("the snippet names its background child")
1020            .trim()
1021            .parse()
1022            .unwrap();
1023        // The guard has finished before `execute` returns; what remains is the
1024        // reap of the killed child by the system.
1025        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
1026        // SAFETY: signal 0 only probes for existence.
1027        while unsafe { libc::kill(pid, 0) } == 0 {
1028            assert!(
1029                std::time::Instant::now() < deadline,
1030                "a background child of the snippet outlived the run"
1031            );
1032            std::thread::sleep(std::time::Duration::from_millis(20));
1033        }
1034        fixture.complete();
1035    }
1036
1037    /// Q13: a sandbox run's command is not executed before its guard is
1038    /// ready. A caller killed in between leaves nothing running, and the
1039    /// command's program never ran.
1040    #[test]
1041    #[cfg(unix)]
1042    fn a_run_never_starts_before_its_guard_is_ready() {
1043        crate::shell::containment::tests::run_external_fixture(
1044            module_path!(),
1045            "run_caller_killed_fixture",
1046        );
1047    }
1048
1049    /// Body of the test above: the stand-in below is killed between "the
1050    /// command's process exists" and "attach its guard".
1051    #[test]
1052    #[ignore = "fixture body of a_run_never_starts_before_its_guard_is_ready"]
1053    #[cfg(unix)]
1054    fn run_caller_killed_fixture() {
1055        let fixture = crate::shell::containment::tests::external_fixture();
1056        crate::shell::containment::tests::caller_killed_before_its_guard(
1057            module_path!(),
1058            "run_caller_standin",
1059        );
1060        fixture.complete();
1061    }
1062
1063    /// Stands in for a process in the middle of a sandbox run.
1064    #[test]
1065    #[ignore = "stand-in caller of run_caller_killed_fixture"]
1066    #[cfg(unix)]
1067    fn run_caller_standin() {
1068        use std::os::unix::process::CommandExt as _;
1069        let marker = crate::shell::containment::tests::caller_standin_marker();
1070        let mut command = std::process::Command::new("sh");
1071        command
1072            .args([
1073                "-c",
1074                &format!("echo ran > '{}'; sleep 60", marker.display()),
1075            ])
1076            .stdin(std::process::Stdio::null())
1077            .stdout(std::process::Stdio::piped())
1078            .stderr(std::process::Stdio::piped())
1079            .process_group(0);
1080        let _ = super::wait_with_timeout(&mut command, 60, |error| error.to_string());
1081    }
1082
1083    #[test]
1084    fn execute_unsupported_language() {
1085        let result = execute("brainfuck", "++++", None);
1086        assert_eq!(result.exit_code, 1);
1087        assert!(result.stderr.contains("Unsupported language"));
1088    }
1089
1090    #[test]
1091    fn execute_rejects_oversized_code() {
1092        let huge = "a".repeat(MAX_CODE_BYTES + 1);
1093        let result = execute("python", &huge, None);
1094        assert_eq!(result.exit_code, 1);
1095        assert!(result.stderr.contains("exceeds the"));
1096    }
1097
1098    #[cfg(windows)]
1099    #[test]
1100    fn sandbox_env_preserves_system_drive() {
1101        let expected =
1102            std::env::var_os("SystemDrive").expect("SystemDrive must be defined on Windows");
1103
1104        let runtime = RuntimeConfig {
1105            command: "cmd".to_string(),
1106            args: Vec::new(),
1107            needs_temp_file: false,
1108            file_extension: "bat".to_string(),
1109            env: HashMap::new(),
1110        };
1111
1112        let mut cmd = Command::new("cmd");
1113        apply_sandbox_env(&mut cmd, &runtime);
1114
1115        let preserved = cmd.get_envs().any(|(key, value)| {
1116            key.to_string_lossy().eq_ignore_ascii_case("SystemDrive")
1117                && value == Some(expected.as_os_str())
1118        });
1119
1120        assert!(
1121            preserved,
1122            "Windows sandbox child environment must preserve SystemDrive"
1123        );
1124    }
1125
1126    #[test]
1127    fn execute_python_error() {
1128        let _lock = crate::core::data_dir::test_env_lock();
1129        if !python_available() {
1130            return;
1131        }
1132        let result = execute("python", "raise ValueError('test error')", None);
1133        assert_ne!(result.exit_code, 0);
1134        assert!(result.stderr.contains("ValueError"));
1135    }
1136
1137    #[test]
1138    fn execute_with_timeout() {
1139        let _lock = crate::core::data_dir::test_env_lock();
1140        if !python_available() {
1141            return;
1142        }
1143        let result = execute("python", "import time; time.sleep(60)", Some(1));
1144        assert_ne!(result.exit_code, 0);
1145        assert!(
1146            result.stderr.contains("timed out"),
1147            "stderr should mention timeout: {}",
1148            result.stderr
1149        );
1150    }
1151
1152    /// GH #1504: partial stdout emitted before a timeout must be preserved.
1153    #[test]
1154    fn timeout_preserves_partial_output() {
1155        let _lock = crate::core::data_dir::test_env_lock();
1156        if !python_available() {
1157            return;
1158        }
1159        let result = execute(
1160            "python",
1161            "import sys; sys.stdout.write('PARTIAL_1504'); sys.stdout.flush(); import time; time.sleep(60)",
1162            Some(2),
1163        );
1164        assert_ne!(result.exit_code, 0);
1165        assert!(
1166            result.stdout.contains("PARTIAL_1504"),
1167            "partial stdout before timeout must be preserved, got: {:?}",
1168            result.stdout
1169        );
1170        assert!(
1171            result.stderr.contains("timed out"),
1172            "stderr should mention timeout: {}",
1173            result.stderr
1174        );
1175    }
1176
1177    #[test]
1178    fn truncate_preserves_head_and_tail() {
1179        let lines: Vec<String> = (0..100)
1180            .map(|i| format!("line {i}: some content here"))
1181            .collect();
1182        let output = lines.join("\n");
1183        let truncated = truncate_smart(&output, 500);
1184        assert!(truncated.contains("line 0:"));
1185        assert!(truncated.contains("line 99:"));
1186        assert!(truncated.contains("truncated"));
1187    }
1188
1189    #[test]
1190    fn supported_languages_list() {
1191        let langs = supported_languages();
1192        assert!(langs.contains(&"python"));
1193        assert!(langs.contains(&"javascript"));
1194        assert!(langs.contains(&"rust"));
1195        assert_eq!(langs.len(), 11);
1196    }
1197
1198    #[test]
1199    fn sandbox_env_is_set() {
1200        let _lock = crate::core::data_dir::test_env_lock();
1201        if !python_available() {
1202            return;
1203        }
1204        let result = execute(
1205            "python",
1206            "import os; print(os.environ.get('LEAN_CTX_SANDBOX', 'missing'))",
1207            None,
1208        );
1209        assert_eq!(result.exit_code, 0);
1210        assert!(result.stdout.contains('1'));
1211    }
1212
1213    #[test]
1214    #[cfg(not(target_os = "windows"))]
1215    fn batch_execute_multiple() {
1216        let _lock = crate::core::data_dir::test_env_lock();
1217        let items = vec![
1218            ("python".to_string(), "print(1+1)".to_string()),
1219            ("shell".to_string(), "echo hello".to_string()),
1220        ];
1221        let results = batch_execute(&items);
1222        assert_eq!(results.len(), 2);
1223        assert!(results[0].stdout.contains('2'));
1224        assert!(results[1].stdout.contains("hello"));
1225    }
1226}