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_executable(&["python3", "python"])
85 .ok_or_else(|| "Python is not installed or not on PATH.".to_string())?;
86
87 let child = Command::new(&python)
88 .args([
89 "-c",
90 &wrap_python(code),
92 ])
93 .stdin(Stdio::null())
94 .stdout(Stdio::piped())
95 .stderr(Stdio::piped())
96 .env_clear()
98 .env("PYTHONDONTWRITEBYTECODE", "1")
99 .env("PYTHONIOENCODING", "utf-8")
100 .spawn()
101 .map_err(|e| format!("Failed to spawn Python: {e}"))?;
102
103 collect_output(child, "python", timeout_secs)
104}
105
106fn wrap_python(code: &str) -> String {
109 format!(
110 r#"
111import sys
112
113# Block network access
114import socket as _socket
115_socket.socket = None # type: ignore
116
117# Block subprocess and os.system
118import os as _os
119_os.system = lambda *a, **k: (_ for _ in ()).throw(PermissionError("os.system blocked in sandbox"))
120_popen_orig = _os.popen
121_os.popen = lambda *a, **k: (_ for _ in ()).throw(PermissionError("os.popen blocked in sandbox"))
122
123# Block __import__ for subprocess
124_real_import = __builtins__.__import__ if hasattr(__builtins__, '__import__') else __import__
125def _safe_import(name, *args, **kwargs):
126 if name in ('subprocess', 'multiprocessing', 'pty', 'telnetlib', 'ftplib', 'smtplib', 'http', 'urllib', 'requests', 'httpx'):
127 raise ImportError(f"Module '{{name}}' is blocked in the sandbox.")
128 return _real_import(name, *args, **kwargs)
129import builtins
130builtins.__import__ = _safe_import
131
132# Run the actual code
133exec(compile(r"""{code}""", "<sandbox>", "exec"))
134"#,
135 code = code.replace(r#"""""#, r#"\" \" \""#)
136 )
137}
138
139fn write_stdin(child: &mut std::process::Child, code: &str) -> Result<(), String> {
140 let mut stdin = child
141 .stdin
142 .take()
143 .ok_or("Failed to open stdin for sandbox process")?;
144 stdin
145 .write_all(code.as_bytes())
146 .map_err(|e| format!("Failed to write to sandbox stdin: {e}"))?;
147 drop(stdin); Ok(())
149}
150
151fn collect_output(
152 child: std::process::Child,
153 runtime: &str,
154 timeout_secs: u64,
155) -> Result<String, String> {
156 let timeout = Duration::from_secs(timeout_secs);
157 let start = std::time::Instant::now();
158
159 let output = std::thread::scope(|s| {
161 let handle = s.spawn(|| child.wait_with_output());
162 loop {
163 if start.elapsed() >= timeout {
164 return Err(format!(
165 "Sandbox timeout: process exceeded {}s and was killed.",
166 timeout_secs
167 ));
168 }
169 std::thread::sleep(Duration::from_millis(50));
170 if handle.is_finished() {
171 return handle
172 .join()
173 .map_err(|_| "Sandbox thread panicked.".to_string())?
174 .map_err(|e| format!("Failed to collect {runtime} output: {e}"));
175 }
176 }
177 })?;
178
179 let stdout = truncate(
180 &String::from_utf8_lossy(&output.stdout),
181 MAX_OUTPUT_BYTES / 2,
182 );
183 let stderr = truncate(
184 &String::from_utf8_lossy(&output.stderr),
185 MAX_OUTPUT_BYTES / 2,
186 );
187
188 if output.status.success() {
189 if stdout.trim().is_empty() && stderr.trim().is_empty() {
190 Ok("(no output)".to_string())
191 } else if stderr.trim().is_empty() {
192 Ok(stdout)
193 } else {
194 Ok(format!("{stdout}\n[stderr]\n{stderr}"))
195 }
196 } else {
197 let code = output
198 .status
199 .code()
200 .map(|c| c.to_string())
201 .unwrap_or_else(|| "?".to_string());
202 Err(format!(
203 "Exit code {code}\n{}\n{}",
204 stdout.trim(),
205 stderr.trim()
206 ))
207 }
208}
209
210fn truncate(s: &str, max: usize) -> String {
211 if s.len() <= max {
212 s.to_string()
213 } else {
214 format!("{}\n... [truncated]", &s[..max])
215 }
216}
217
218fn find_deno() -> Option<String> {
225 let config = crate::agent::config::load_config();
227 if let Some(path) = config.deno_path {
228 if std::path::Path::new(&path).exists() {
229 return Some(path);
230 }
231 }
232
233 let exe = if cfg!(windows) { "deno.exe" } else { "deno" };
234
235 if let Ok(home) = std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME")) {
237 let standard = std::path::Path::new(&home)
238 .join(".deno")
239 .join("bin")
240 .join(exe);
241 if standard.exists() {
242 return Some(standard.to_string_lossy().into_owned());
243 }
244 }
245
246 if cfg!(windows) {
249 if let Ok(local_app) = std::env::var("LOCALAPPDATA") {
250 let winget_base = std::path::Path::new(&local_app)
251 .join("Microsoft")
252 .join("WinGet")
253 .join("Packages");
254 if let Ok(entries) = std::fs::read_dir(&winget_base) {
255 for entry in entries.flatten() {
256 let name = entry.file_name();
257 if name.to_string_lossy().starts_with("DenoLand.Deno") {
258 let candidate = entry.path().join("deno.exe");
259 if candidate.exists() {
260 return Some(candidate.to_string_lossy().into_owned());
261 }
262 }
263 }
264 }
265 }
266 }
267
268 let check = if cfg!(windows) {
270 Command::new("where").arg("deno").output()
271 } else {
272 Command::new("which").arg("deno").output()
273 };
274 if let Ok(out) = check {
275 if out.status.success() {
276 let resolved = String::from_utf8_lossy(&out.stdout)
278 .trim()
279 .lines()
280 .next()
281 .unwrap_or("deno")
282 .to_string();
283 return Some(resolved);
284 }
285 }
286
287 find_lmstudio_deno()
289}
290
291fn find_executable(candidates: &[&str]) -> Option<String> {
293 for name in candidates {
294 let check = if cfg!(windows) {
295 Command::new("where").arg(name).output()
296 } else {
297 Command::new("which").arg(name).output()
298 };
299 if check.map(|o| o.status.success()).unwrap_or(false) {
300 return Some(name.to_string());
301 }
302 }
303 None
304}
305
306fn find_lmstudio_deno() -> Option<String> {
309 let home = std::env::var("USERPROFILE")
310 .or_else(|_| std::env::var("HOME"))
311 .ok()?;
312
313 let exe = if cfg!(windows) { "deno.exe" } else { "deno" };
314 let path = std::path::Path::new(&home)
315 .join(".lmstudio")
316 .join(".internal")
317 .join("utils")
318 .join(exe);
319
320 if path.exists() {
321 Some(path.to_string_lossy().into_owned())
322 } else {
323 None
324 }
325}