Skip to main content

hematite/tools/
code_sandbox.rs

1//! Sandboxed code execution tool.
2//!
3//! Lets the model write and run code in a restricted subprocess — no network,
4//! no filesystem escape, hard timeout. Supports JavaScript/TypeScript (Deno)
5//! and Python. Neither runtime is bundled; we detect what's available and
6//! report clearly when nothing is found.
7
8use serde_json::Value;
9use std::io::Write;
10use std::process::{Command, Stdio};
11use std::time::Duration;
12
13const DEFAULT_TIMEOUT_SECS: u64 = 10;
14const MAX_TIMEOUT_SECS: u64 = 60;
15const MAX_OUTPUT_BYTES: usize = 16_384; // 16 KB — enough for any reasonable script result
16
17pub async fn execute(args: &Value) -> Result<String, String> {
18    let language = args
19        .get("language")
20        .and_then(|v| v.as_str())
21        .unwrap_or("javascript")
22        .to_lowercase();
23
24    let code = args
25        .get("code")
26        .and_then(|v| v.as_str())
27        .ok_or("Missing required argument: 'code'")?;
28
29    let timeout_secs = args
30        .get("timeout_seconds")
31        .and_then(|v| v.as_u64())
32        .unwrap_or(DEFAULT_TIMEOUT_SECS)
33        .min(MAX_TIMEOUT_SECS);
34
35    match language.as_str() {
36        "javascript" | "typescript" | "js" | "ts" => run_deno(code, timeout_secs),
37        "python" | "python3" | "py" => run_python(code, timeout_secs),
38        other => Err(format!(
39            "Unsupported language: '{}'. Supported: javascript, typescript, python.",
40            other
41        )),
42    }
43}
44
45/// Run code via Deno with strict permission flags.
46/// Uses stdin so no temp file is needed.
47fn run_deno(code: &str, timeout_secs: u64) -> Result<String, String> {
48    let deno = find_deno().ok_or_else(|| {
49        "Deno not found. Hematite checks (in order): settings.json `deno_path`, \
50         LM Studio's bundled copy (~/.lmstudio/.internal/utils/deno.exe), system PATH. \
51         To install Deno globally: `winget install DenoLand.Deno` (Windows) or see https://deno.com. \
52         Or set `deno_path` in .hematite/settings.json to point to any Deno binary."
53            .to_string()
54    })?;
55
56    let mut child = Command::new(&deno)
57        .args([
58            "run",
59            "--allow-read=.",  // workspace only
60            "--allow-write=.", // workspace only
61            "--deny-net",      // no outbound network
62            "--deny-sys",      // no OS info
63            "--deny-env",      // no environment variable access
64            "--deny-run",      // no spawning other processes
65            "--deny-ffi",      // no native library calls (FFI escape vector)
66            "--no-prompt",     // never ask for permissions interactively
67            "-",               // read from stdin — no temp file needed
68        ])
69        .env("NO_COLOR", "true") // clean output, no ANSI codes in results
70        .stdin(Stdio::piped())
71        .stdout(Stdio::piped())
72        .stderr(Stdio::piped())
73        .spawn()
74        .map_err(|e| format!("Failed to spawn Deno: {e}"))?;
75
76    write_stdin(&mut child, code)?;
77    collect_output(child, "deno", timeout_secs)
78}
79
80/// Run code via Python with network and subprocess access blocked at env level.
81/// Python has no built-in permission flags like Deno, so we restrict the
82/// environment and use a short timeout as the primary safety net.
83fn run_python(code: &str, timeout_secs: u64) -> Result<String, String> {
84    let python = find_python().ok_or_else(|| {
85        "Python not found. Hematite checks (in order): settings.json `python_path`, \
86         system PATH (python3, python), and 'py' launcher on Windows. \
87         To install Python: https://python.org or `winget install Python.Python.3`. \
88         Or set `python_path` in .hematite/settings.json to point to any Python binary."
89            .to_string()
90    })?;
91
92    let mut cmd = Command::new(&python);
93    if python == "py" {
94        cmd.arg("-3");
95    }
96    // Wipe the parent environment so the subprocess cannot read API keys,
97    // tokens, or other secrets from env vars. Add back only the minimum
98    // variables Python needs to function on each platform.
99    let mut builder = cmd.args([
100        "-c",
101        // Wrap the code: block network imports and dangerous builtins before running.
102        &wrap_python(code),
103    ]);
104    builder = builder
105        .stdin(Stdio::null())
106        .stdout(Stdio::piped())
107        .stderr(Stdio::piped())
108        .env_clear()
109        .env("PYTHONDONTWRITEBYTECODE", "1")
110        .env("PYTHONIOENCODING", "utf-8");
111
112    // Windows needs SYSTEMROOT for DLL loading; forward TEMP/TMP so the
113    // tempfile module works if sandboxed code needs it.
114    #[cfg(target_os = "windows")]
115    {
116        if let Ok(v) = std::env::var("SYSTEMROOT") {
117            builder = builder.env("SYSTEMROOT", v);
118        }
119        if let Ok(v) = std::env::var("TEMP") {
120            builder = builder.env("TEMP", v);
121        }
122        if let Ok(v) = std::env::var("TMP") {
123            builder = builder.env("TMP", v);
124        }
125    }
126    #[cfg(not(target_os = "windows"))]
127    {
128        if let Ok(v) = std::env::var("TMPDIR") {
129            builder = builder.env("TMPDIR", v);
130        }
131    }
132
133    let child = builder
134        .spawn()
135        .map_err(|e| format!("Failed to spawn Python: {e}"))?;
136
137    collect_output(child, "python", timeout_secs)
138}
139
140/// Wraps the user's Python in a minimal sandbox: blocks socket, subprocess,
141/// os.system, and __import__ to prevent the most obvious escapes.
142/// Also pre-imports the full math/stats stdlib so the model never needs to.
143fn wrap_python(code: &str) -> String {
144    format!(
145        r#"
146import sys
147
148# Block network access
149import socket as _socket
150_socket.socket = None  # type: ignore
151
152# Block subprocess and os.system
153import os as _os
154_os.system = lambda *a, **k: (_ for _ in ()).throw(PermissionError("os.system blocked in sandbox"))
155_popen_orig = _os.popen
156_os.popen = lambda *a, **k: (_ for _ in ()).throw(PermissionError("os.popen blocked in sandbox"))
157
158# Block __import__ for subprocess
159_real_import = __builtins__.__import__ if hasattr(__builtins__, '__import__') else __import__
160def _safe_import(name, *args, **kwargs):
161    if name in ('subprocess', 'multiprocessing', 'pty', 'telnetlib', 'ftplib', 'smtplib', 'http', 'urllib', 'requests', 'httpx'):
162        raise ImportError(f"Module '{{name}}' is blocked in the sandbox.")
163    return _real_import(name, *args, **kwargs)
164import builtins
165builtins.__import__ = _safe_import
166
167# ── Math / data science prelude — always available, no import needed ──────────
168import math
169import statistics
170import datetime
171import json
172import re
173import decimal
174import fractions
175import itertools
176from collections import Counter, defaultdict, OrderedDict
177from functools import reduce
178
179# Optional: numpy / pandas — available if installed on the host
180try:
181    import numpy as np
182    import pandas as pd
183    HAS_NUMPY = True
184except ImportError:
185    HAS_NUMPY = False
186
187# Run the actual code
188try:
189    exec(compile(r"""{code}""", "<sandbox>", "exec"))
190except Exception as e:
191    print(f"\nSandbox Execution Error: {{e}}", file=sys.stderr)
192    sys.exit(1)
193"#,
194        code = code.replace(r#"""""#, r#"\" \" \""#)
195    )
196}
197
198fn write_stdin(child: &mut std::process::Child, code: &str) -> Result<(), String> {
199    let mut stdin = child
200        .stdin
201        .take()
202        .ok_or("Failed to open stdin for sandbox process")?;
203    stdin
204        .write_all(code.as_bytes())
205        .map_err(|e| format!("Failed to write to sandbox stdin: {e}"))?;
206    drop(stdin); // signal EOF so the process starts
207    Ok(())
208}
209
210fn collect_output(
211    child: std::process::Child,
212    runtime: &str,
213    timeout_secs: u64,
214) -> Result<String, String> {
215    let timeout = Duration::from_secs(timeout_secs);
216    let start = std::time::Instant::now();
217
218    // Poll with wait_with_output — use a thread so we can enforce a timeout.
219    let output = std::thread::scope(|s| {
220        let handle = s.spawn(|| child.wait_with_output());
221        loop {
222            if start.elapsed() >= timeout {
223                return Err(format!(
224                    "Sandbox timeout: process exceeded {}s and was killed.",
225                    timeout_secs
226                ));
227            }
228            std::thread::sleep(Duration::from_millis(50));
229            if handle.is_finished() {
230                return handle
231                    .join()
232                    .map_err(|_| "Sandbox thread panicked.".to_string())?
233                    .map_err(|e| format!("Failed to collect {runtime} output: {e}"));
234            }
235        }
236    })?;
237
238    let stdout = truncate(
239        &String::from_utf8_lossy(&output.stdout),
240        MAX_OUTPUT_BYTES / 2,
241    );
242    let stderr = truncate(
243        &String::from_utf8_lossy(&output.stderr),
244        MAX_OUTPUT_BYTES / 2,
245    );
246
247    if output.status.success() {
248        if stdout.trim().is_empty() && stderr.trim().is_empty() {
249            Ok("(no output)".to_string())
250        } else if stderr.trim().is_empty() {
251            Ok(stdout)
252        } else {
253            Ok(format!("{stdout}\n[stderr]\n{stderr}"))
254        }
255    } else {
256        let code = output
257            .status
258            .code()
259            .map(|c| c.to_string())
260            .unwrap_or_else(|| "?".to_string());
261        Err(format!(
262            "Exit code {code}\n{}\n{}",
263            stdout.trim(),
264            stderr.trim()
265        ))
266    }
267}
268
269fn truncate(s: &str, max: usize) -> String {
270    if s.len() <= max {
271        s.to_string()
272    } else {
273        format!("{}\n... [truncated]", &s[..max])
274    }
275}
276
277/// Locate Deno with a priority-ordered search:
278/// 1. `deno_path` in .hematite/settings.json (explicit user pin)
279/// 2. Standard deno install location (~/.deno/bin/deno.exe)
280/// 3. WinGet package location (winget doesn't always add to PATH correctly)
281/// 4. System PATH via where/which
282/// 5. LM Studio's bundled copy — automatic fallback for all LM Studio users
283fn find_deno() -> Option<String> {
284    let config = crate::agent::config::load_config();
285    if let Some(path) = config.deno_path {
286        if std::path::Path::new(&path).exists() {
287            return Some(path);
288        }
289    }
290
291    let exe = if cfg!(windows) { "deno.exe" } else { "deno" };
292
293    // 2. Standard deno install path (deno's own installer puts it here)
294    if let Ok(home) = std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME")) {
295        let standard = std::path::Path::new(&home)
296            .join(".deno")
297            .join("bin")
298            .join(exe);
299        if standard.exists() {
300            return Some(standard.to_string_lossy().into_owned());
301        }
302    }
303
304    // 3. WinGet package location — winget installs Deno here but doesn't always
305    //    wire PATH correctly for non-PowerShell processes
306    if cfg!(windows) {
307        if let Ok(local_app) = std::env::var("LOCALAPPDATA") {
308            let winget_base = std::path::Path::new(&local_app)
309                .join("Microsoft")
310                .join("WinGet")
311                .join("Packages");
312            if let Ok(entries) = std::fs::read_dir(&winget_base) {
313                for entry in entries.flatten() {
314                    let name = entry.file_name();
315                    if name.to_string_lossy().starts_with("DenoLand.Deno") {
316                        let candidate = entry.path().join("deno.exe");
317                        if candidate.exists() {
318                            return Some(candidate.to_string_lossy().into_owned());
319                        }
320                    }
321                }
322            }
323        }
324    }
325
326    // 4. System PATH
327    let check = if cfg!(windows) {
328        Command::new("where").arg("deno").output()
329    } else {
330        Command::new("which").arg("deno").output()
331    };
332    if let Ok(out) = check {
333        if out.status.success() {
334            // Use the resolved path, not just "deno", to avoid shim ambiguity
335            let resolved = String::from_utf8_lossy(&out.stdout)
336                .trim()
337                .lines()
338                .next()
339                .unwrap_or("deno")
340                .to_string();
341            return Some(resolved);
342        }
343    }
344
345    // 5. LM Studio bundled copy — last resort
346    find_lmstudio_deno()
347}
348
349fn find_python() -> Option<String> {
350    let config = crate::agent::config::load_config();
351    if let Some(path) = config.python_path {
352        if std::path::Path::new(&path).exists() {
353            return Some(path);
354        }
355    }
356    find_executable(&["python3", "python", "py"])
357}
358
359/// Find the first available executable from a list of candidates.
360fn find_executable(candidates: &[&str]) -> Option<String> {
361    for name in candidates {
362        let check = if cfg!(windows) {
363            Command::new("where").arg(name).output()
364        } else {
365            Command::new("which").arg(name).output()
366        };
367        if check.map(|o| o.status.success()).unwrap_or(false) {
368            return Some(name.to_string());
369        }
370    }
371    None
372}
373
374/// Returns the path to Deno bundled inside LM Studio's internal utils folder.
375/// LM Studio ships Deno at `~/.lmstudio/.internal/utils/deno[.exe]` on all platforms.
376fn find_lmstudio_deno() -> Option<String> {
377    let home = std::env::var("USERPROFILE")
378        .or_else(|_| std::env::var("HOME"))
379        .ok()?;
380
381    let exe = if cfg!(windows) { "deno.exe" } else { "deno" };
382    let path = std::path::Path::new(&home)
383        .join(".lmstudio")
384        .join(".internal")
385        .join("utils")
386        .join(exe);
387
388    if path.exists() {
389        Some(path.to_string_lossy().into_owned())
390    } else {
391        None
392    }
393}