hematite/tools/
code_sandbox.rs1use 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; pub 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
45fn 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=.", "--allow-write=.", "--deny-net", "--deny-sys", "--deny-env", "--deny-run", "--deny-ffi", "--no-prompt", "-", ])
69 .env("NO_COLOR", "true") .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
80fn 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 let mut builder = cmd.args([
100 "-c",
101 &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 #[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
140fn 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); 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 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
277fn 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 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 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 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 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 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
359fn 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
374fn 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}