adk_code/rust_sandbox.rs
1//! Rust sandbox executor — the flagship Rust-authored code execution backend.
2//!
3//! [`RustSandboxExecutor`] compiles and executes authored Rust in an isolated
4//! environment using a host-local process approach (phase 1). It wraps user code
5//! in a harness that injects JSON input via stdin and captures JSON output from
6//! stdout.
7//!
8//! # Phase 1 Rust Source Model
9//!
10//! Phase 1 supports **self-contained Rust snippets** compiled into a controlled
11//! harness. This is intentionally bounded — the executor does not support
12//! arbitrary Cargo workspaces, multi-file projects, or external dependency
13//! resolution.
14//!
15//! ## The `run()` Contract
16//!
17//! User code must provide exactly one entry point:
18//!
19//! ```rust,ignore
20//! fn run(input: serde_json::Value) -> serde_json::Value
21//! ```
22//!
23//! The harness wraps this function with a generated `fn main()` that:
24//!
25//! 1. Reads JSON input from stdin
26//! 2. Calls the user's `run()` function
27//! 3. Writes JSON output to stdout as the last line
28//!
29//! ## Available Imports and Dependencies
30//!
31//! The harness automatically provides:
32//!
33//! | Dependency | Version | Notes |
34//! |----------------|---------|-------|
35//! | `serde_json` | workspace-pinned | Re-exported as `serde_json::Value`, `serde_json::json!`, etc. |
36//!
37//! The harness injects `use serde_json::Value;` at the top. User code can
38//! reference any public item from `serde_json` (e.g., `serde_json::json!`,
39//! `serde_json::Map`, `serde_json::Number`).
40//!
41//! No other external crates are available. The Rust standard library is fully
42//! available (`std::collections`, `std::fmt`, etc.).
43//!
44//! ## What Is NOT Supported (Phase 1)
45//!
46//! - **`fn main()`**: The harness provides `main()`. User code that defines its
47//! own `fn main()` will be rejected with an [`ExecutionError::InvalidRequest`].
48//! - **`Cargo.toml`**: There is no Cargo project. Compilation uses `rustc` directly.
49//! - **External crates**: Only `serde_json` is linked. `use some_other_crate::*`
50//! will produce a compile error.
51//! - **Multi-file projects**: The source model is a single code string. No `mod`
52//! declarations referencing external files.
53//! - **Procedural macros or build scripts**: Not available.
54//! - **`#![...]` crate-level attributes**: Not supported in the harness body.
55//!
56//! ## Phase 1 Isolation Model
57//!
58//! Phase 1 uses host-local process execution via `rustc`. The backend is honest
59//! about its capabilities:
60//!
61//! - **Timeout enforcement**: Yes (via `tokio::time::timeout`), covering stdin
62//! delivery as well as the run
63//! - **Output truncation**: Yes (configurable limits, applied while reading)
64//! - **Network restriction**: No (host-local cannot enforce this)
65//! - **Filesystem restriction**: No (host-local cannot enforce this)
66//! - **Environment restriction**: Yes. `rustc` starts from a cleared environment
67//! plus the toolchain allowlist from
68//! [`ProcessBackend::toolchain_env`](adk_sandbox::ProcessBackend::toolchain_env)
69//! (`PATH`, `HOME`, `RUSTUP_HOME`, and similar), so `env!` and `option_env!`
70//! cannot embed host credentials. The binary starts from a cleared environment
71//! plus the variables named by [`EnvironmentPolicy::AllowList`](crate::EnvironmentPolicy)
72//! (and `SystemRoot` on Windows).
73//!
74//! ## Example
75//!
76//! ```rust,no_run
77//! # async fn example() -> Result<(), adk_code::ExecutionError> {
78//! use adk_code::{
79//! CodeExecutor, ExecutionLanguage, ExecutionPayload, ExecutionRequest,
80//! ExecutionStatus, SandboxPolicy, RustSandboxExecutor,
81//! };
82//!
83//! let executor = RustSandboxExecutor::default();
84//! let request = ExecutionRequest {
85//! language: ExecutionLanguage::Rust,
86//! payload: ExecutionPayload::Source {
87//! code: r#"
88//! fn run(input: serde_json::Value) -> serde_json::Value {
89//! let v = input["value"].as_i64().unwrap_or(0);
90//! serde_json::json!({ "doubled": v * 2 })
91//! }
92//! "#.to_string(),
93//! },
94//! argv: vec![],
95//! stdin: None,
96//! input: Some(serde_json::json!({ "value": 21 })),
97//! sandbox: SandboxPolicy::default(),
98//! identity: None,
99//! };
100//!
101//! let result = executor.execute(request).await?;
102//! assert_eq!(result.status, ExecutionStatus::Success);
103//! assert_eq!(result.output, Some(serde_json::json!({ "doubled": 42 })));
104//! # Ok(())
105//! # }
106//! ```
107
108use std::path::PathBuf;
109use std::process::Stdio;
110use std::time::Instant;
111
112use async_trait::async_trait;
113use tracing::{debug, info, instrument, warn};
114
115use crate::harness::{
116 HARNESS_TEMPLATE, extract_structured_output, truncate_output, validate_rust_source,
117};
118use crate::host_process;
119use crate::{
120 BackendCapabilities, CodeExecutor, ExecutionError, ExecutionIsolation, ExecutionLanguage,
121 ExecutionPayload, ExecutionRequest, ExecutionResult, ExecutionStatus, validate_request,
122};
123
124/// Configuration for the Rust sandbox executor.
125///
126/// # Example
127///
128/// ```rust
129/// use adk_code::RustSandboxConfig;
130///
131/// let config = RustSandboxConfig::default();
132/// assert_eq!(config.rustc_path, "rustc");
133/// ```
134#[derive(Debug, Clone)]
135pub struct RustSandboxConfig {
136 /// Path to the `rustc` compiler binary.
137 pub rustc_path: String,
138 /// Extra flags passed to `rustc` during compilation.
139 pub rustc_flags: Vec<String>,
140 /// Path to the `serde_json` rlib or directory containing it.
141 /// If `None`, the executor will attempt to locate it automatically.
142 pub serde_json_path: Option<PathBuf>,
143}
144
145impl Default for RustSandboxConfig {
146 fn default() -> Self {
147 Self { rustc_path: "rustc".to_string(), rustc_flags: vec![], serde_json_path: None }
148 }
149}
150
151/// The flagship Rust-authored code execution backend.
152///
153/// Compiles and executes authored Rust using a host-local process approach.
154/// Phase 1 is honest about isolation: it can enforce timeouts and output
155/// truncation, but cannot enforce network or filesystem restrictions.
156///
157/// # Backend Capability Reporting
158///
159/// `RustSandboxExecutor` reports its capabilities truthfully through
160/// [`BackendCapabilities`]. The phase 1 implementation uses host-local
161/// process execution (`rustc` + spawned binary), so:
162///
163/// | Capability | Enforced | Reason |
164/// |---|---|---|
165/// | Isolation class | `HostLocal` | Runs as a local process, not in a container |
166/// | Network policy | No | Host-local processes inherit host network access |
167/// | Filesystem policy | No | Host-local processes inherit host filesystem access |
168/// | Environment policy | Yes | `rustc` sees only the toolchain allowlist; the binary sees only the policy's allowlist |
169/// | Timeout | Yes | Enforced via `tokio::time::timeout`, including stdin delivery |
170/// | Structured output | Yes | Harness extracts JSON from last stdout line |
171/// | Process execution | No | User code cannot spawn child processes through the harness |
172/// | Persistent workspace | No | Each execution uses a fresh temp directory |
173/// | Interactive sessions | No | Single-shot execution only |
174///
175/// Callers should use `validate_policy` to check whether a requested
176/// `SandboxPolicy` is compatible with these capabilities before execution.
177/// If a policy requests a control the backend cannot enforce (e.g., disabled
178/// network), validation fails with [`ExecutionError::UnsupportedPolicy`].
179///
180/// # Example
181///
182/// ```rust
183/// use adk_code::{CodeExecutor, RustSandboxExecutor, ExecutionIsolation};
184///
185/// let executor = RustSandboxExecutor::default();
186/// assert_eq!(executor.name(), "rust-sandbox");
187/// assert_eq!(executor.capabilities().isolation, ExecutionIsolation::HostLocal);
188/// assert!(executor.capabilities().enforce_timeout);
189/// assert!(executor.capabilities().enforce_environment_policy);
190/// assert!(!executor.capabilities().enforce_network_policy);
191/// ```
192#[derive(Debug, Clone)]
193pub struct RustSandboxExecutor {
194 config: RustSandboxConfig,
195}
196
197impl RustSandboxExecutor {
198 /// Create a new executor with the given configuration.
199 pub fn new(config: RustSandboxConfig) -> Self {
200 Self { config }
201 }
202}
203
204impl Default for RustSandboxExecutor {
205 fn default() -> Self {
206 Self::new(RustSandboxConfig::default())
207 }
208}
209
210#[async_trait]
211impl CodeExecutor for RustSandboxExecutor {
212 fn name(&self) -> &str {
213 "rust-sandbox"
214 }
215
216 fn capabilities(&self) -> BackendCapabilities {
217 BackendCapabilities {
218 isolation: ExecutionIsolation::HostLocal,
219 enforce_network_policy: false,
220 enforce_filesystem_policy: false,
221 enforce_environment_policy: true,
222 enforce_timeout: true,
223 supports_structured_output: true,
224 supports_process_execution: false,
225 supports_persistent_workspace: false,
226 supports_interactive_sessions: false,
227 }
228 }
229
230 fn supports_language(&self, lang: &ExecutionLanguage) -> bool {
231 matches!(lang, ExecutionLanguage::Rust)
232 }
233
234 #[instrument(skip_all, fields(backend = "rust-sandbox", language = "Rust"))]
235 async fn execute(&self, request: ExecutionRequest) -> Result<ExecutionResult, ExecutionError> {
236 // Validate the request against our capabilities.
237 validate_request(&self.capabilities(), &[ExecutionLanguage::Rust], &request)?;
238
239 let code = match &request.payload {
240 ExecutionPayload::Source { code } => code.clone(),
241 ExecutionPayload::GuestModule { .. } => {
242 return Err(ExecutionError::InvalidRequest(
243 "RustSandboxExecutor only accepts Source payloads".to_string(),
244 ));
245 }
246 };
247
248 // Validate that the source fits the phase 1 bounded model.
249 validate_rust_source(&code)?;
250
251 let start = Instant::now();
252
253 // Create a temp directory for compilation artifacts.
254 let tmp_dir = tempfile::tempdir().map_err(|e| {
255 ExecutionError::ExecutionFailed(format!("failed to create temp directory: {e}"))
256 })?;
257
258 let source_path = tmp_dir.path().join("main.rs");
259 let binary_path = tmp_dir.path().join("main");
260
261 // Write the harnessed source file.
262 let harnessed_source = HARNESS_TEMPLATE.replace("{user_code}", &code);
263 tokio::fs::write(&source_path, &harnessed_source).await.map_err(|e| {
264 ExecutionError::ExecutionFailed(format!("failed to write source file: {e}"))
265 })?;
266
267 debug!(source_path = %source_path.display(), "wrote harnessed source");
268
269 // ── Compilation ────────────────────────────────────────────────
270 let compile_result = self.compile(&source_path, &binary_path, &request).await?;
271 if let Some(result) = compile_result {
272 // Compilation failed — return the compile failure result.
273 return Ok(result);
274 }
275
276 info!("compilation succeeded, executing binary");
277
278 // ── Execution ──────────────────────────────────────────────────
279 let result = self.run_binary(&binary_path, &request, start).await;
280
281 // Clean up temp dir (best-effort, tempfile handles this on drop too).
282 drop(tmp_dir);
283
284 result
285 }
286}
287
288impl RustSandboxExecutor {
289 /// Compile the source file. Returns `Ok(Some(result))` if compilation failed
290 /// (with a `CompileFailed` result), `Ok(None)` if compilation succeeded,
291 /// or `Err` for infrastructure failures.
292 async fn compile(
293 &self,
294 source_path: &std::path::Path,
295 binary_path: &std::path::Path,
296 request: &ExecutionRequest,
297 ) -> Result<Option<ExecutionResult>, ExecutionError> {
298 let serde_json_dep = self.find_serde_json_dep().await?;
299
300 let mut cmd = host_process::rustc_command(&self.config.rustc_path);
301 cmd.arg(source_path).arg("-o").arg(binary_path).arg("--edition").arg("2021");
302
303 // Link against serde_json.
304 if let Some(dep_path) = &serde_json_dep {
305 cmd.arg("--extern").arg(format!("serde_json={}", dep_path.display()));
306
307 // Add the parent directory to the library search path so transitive
308 // deps (serde, itoa, ryu, memchr, etc.) can be found.
309 if let Some(parent) = dep_path.parent() {
310 cmd.arg("-L").arg(format!("dependency={}", parent.display()));
311 }
312 }
313
314 // Add any extra flags from config.
315 for flag in &self.config.rustc_flags {
316 cmd.arg(flag);
317 }
318
319 // Capture stdout and stderr.
320 cmd.stdout(std::process::Stdio::piped());
321 cmd.stderr(std::process::Stdio::piped());
322
323 let compile_timeout = request.sandbox.timeout;
324 let compile_output = match tokio::time::timeout(compile_timeout, cmd.output()).await {
325 Ok(Ok(output)) => output,
326 Ok(Err(e)) => {
327 return Err(ExecutionError::CompileFailed(format!("failed to invoke rustc: {e}")));
328 }
329 Err(_) => {
330 return Ok(Some(ExecutionResult {
331 status: ExecutionStatus::Timeout,
332 stdout: String::new(),
333 stderr: "compilation timed out".to_string(),
334 output: None,
335 exit_code: None,
336 stdout_truncated: false,
337 stderr_truncated: false,
338 duration_ms: compile_timeout.as_millis() as u64,
339 metadata: None,
340 }));
341 }
342 };
343
344 if !compile_output.status.success() {
345 let stderr = String::from_utf8_lossy(&compile_output.stderr).to_string();
346 let (stderr, stderr_truncated) =
347 truncate_output(stderr, request.sandbox.max_stderr_bytes);
348
349 debug!(exit_code = compile_output.status.code(), "compilation failed");
350
351 return Ok(Some(ExecutionResult {
352 status: ExecutionStatus::CompileFailed,
353 stdout: String::new(),
354 stderr,
355 output: None,
356 exit_code: compile_output.status.code(),
357 stdout_truncated: false,
358 stderr_truncated,
359 duration_ms: 0, // Will be set by caller if needed.
360 metadata: None,
361 }));
362 }
363
364 Ok(None)
365 }
366
367 /// Run the compiled binary with timeout enforcement and output capture.
368 ///
369 /// The binary starts from a cleared environment plus the variables the
370 /// request's [`EnvironmentPolicy`](crate::EnvironmentPolicy) allows.
371 async fn run_binary(
372 &self,
373 binary_path: &std::path::Path,
374 request: &ExecutionRequest,
375 start: Instant,
376 ) -> Result<ExecutionResult, ExecutionError> {
377 // Structured input takes precedence over raw stdin.
378 let stdin = match (&request.input, &request.stdin) {
379 (Some(input), _) => Some(serde_json::to_vec(input).unwrap_or_default()),
380 (None, Some(raw)) => Some(raw.clone()),
381 (None, None) => None,
382 };
383
384 let mut cmd = tokio::process::Command::new(binary_path);
385 cmd.args(&request.argv)
386 .env_clear()
387 .envs(host_process::binary_env(&request.sandbox.environment))
388 .stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() })
389 .stdout(Stdio::piped())
390 .stderr(Stdio::piped())
391 .kill_on_drop(true);
392
393 let child = cmd
394 .spawn()
395 .map_err(|e| ExecutionError::ExecutionFailed(format!("failed to spawn binary: {e}")))?;
396
397 let completed = host_process::run_to_completion(
398 child,
399 stdin,
400 request.sandbox.timeout,
401 request.sandbox.max_stdout_bytes,
402 request.sandbox.max_stderr_bytes,
403 )
404 .await
405 .map_err(|e| ExecutionError::ExecutionFailed(format!("failed to run binary: {e}")))?;
406
407 let Some(output) = completed else {
408 warn!("execution timed out");
409 return Ok(ExecutionResult {
410 status: ExecutionStatus::Timeout,
411 stdout: String::new(),
412 stderr: String::new(),
413 output: None,
414 exit_code: None,
415 stdout_truncated: false,
416 stderr_truncated: false,
417 duration_ms: start.elapsed().as_millis() as u64,
418 metadata: None,
419 });
420 };
421
422 let duration_ms = start.elapsed().as_millis() as u64;
423
424 let raw_stdout = String::from_utf8_lossy(&output.stdout.bytes).into_owned();
425 let raw_stderr = String::from_utf8_lossy(&output.stderr.bytes).into_owned();
426
427 let (stdout, stdout_truncated) =
428 truncate_output(raw_stdout, request.sandbox.max_stdout_bytes);
429 let (stderr, stderr_truncated) =
430 truncate_output(raw_stderr, request.sandbox.max_stderr_bytes);
431 let stdout_truncated = stdout_truncated || output.stdout.truncated;
432 let stderr_truncated = stderr_truncated || output.stderr.truncated;
433
434 // Try to parse the last line of stdout as structured JSON output.
435 // The harness prints the JSON output as the last line.
436 let (structured_output, display_stdout) = extract_structured_output(&stdout);
437
438 let status = if output.status.success() {
439 ExecutionStatus::Success
440 } else {
441 ExecutionStatus::Failed
442 };
443
444 debug!(
445 exit_code = output.status.code(),
446 duration_ms,
447 has_structured_output = structured_output.is_some(),
448 "execution completed"
449 );
450
451 Ok(ExecutionResult {
452 status,
453 stdout: display_stdout,
454 stderr,
455 output: structured_output,
456 exit_code: output.status.code(),
457 stdout_truncated,
458 stderr_truncated,
459 duration_ms,
460 metadata: None,
461 })
462 }
463
464 /// Locate the `serde_json` rlib for linking.
465 ///
466 /// If `serde_json_path` is configured, use that. Otherwise, try to find it
467 /// by querying cargo for the serde_json package location.
468 async fn find_serde_json_dep(&self) -> Result<Option<PathBuf>, ExecutionError> {
469 if let Some(ref path) = self.config.serde_json_path {
470 if path.exists() {
471 return Ok(Some(path.clone()));
472 }
473 return Err(ExecutionError::ExecutionFailed(format!(
474 "configured serde_json path does not exist: {}",
475 path.display()
476 )));
477 }
478
479 // Try to find serde_json rlib in the cargo target directory.
480 // We look for it in the workspace's target/debug/deps directory.
481 let output = tokio::process::Command::new("cargo")
482 .args(["metadata", "--format-version=1", "--no-deps"])
483 .stdout(std::process::Stdio::piped())
484 .stderr(std::process::Stdio::null())
485 .output()
486 .await;
487
488 if let Ok(output) = output
489 && output.status.success()
490 && let Ok(metadata) = serde_json::from_slice::<serde_json::Value>(&output.stdout)
491 && let Some(target_dir) = metadata["target_directory"].as_str()
492 {
493 let deps_dir = PathBuf::from(target_dir).join("debug").join("deps");
494 if let Some(rlib) = find_rlib_in_dir(&deps_dir, "serde_json").await {
495 return Ok(Some(rlib));
496 }
497 }
498
499 // Fallback: return None and let rustc try to find it on its own.
500 // This will likely fail, but the compile error will be descriptive.
501 Ok(None)
502 }
503}
504
505/// Find an rlib file matching the given crate name in a directory.
506///
507/// Picks the most recently modified matching file to avoid issues with stale
508/// artifacts in the cargo deps directory.
509async fn find_rlib_in_dir(dir: &std::path::Path, crate_name: &str) -> Option<PathBuf> {
510 let prefix = format!("lib{crate_name}-");
511 let mut entries = match tokio::fs::read_dir(dir).await {
512 Ok(entries) => entries,
513 Err(_) => return None,
514 };
515
516 let mut rlibs = Vec::new();
517 while let Ok(Some(entry)) = entries.next_entry().await {
518 let name = entry.file_name();
519 let name_str = name.to_string_lossy();
520 if name_str.starts_with(&prefix)
521 && name_str.ends_with(".rlib")
522 && let Ok(metadata) = entry.metadata().await
523 && let Ok(modified) = metadata.modified()
524 {
525 rlibs.push((entry.path(), modified));
526 }
527 }
528
529 // Sort by modification time descending (latest first).
530 rlibs.sort_by_key(|&(_, modified)| std::cmp::Reverse(modified));
531
532 rlibs.into_iter().next().map(|(path, _)| path)
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538
539 #[test]
540 fn capabilities_are_honest() {
541 let executor = RustSandboxExecutor::default();
542 let caps = executor.capabilities();
543 assert_eq!(caps.isolation, ExecutionIsolation::HostLocal);
544 assert!(caps.enforce_timeout);
545 assert!(caps.supports_structured_output);
546 assert!(!caps.enforce_network_policy);
547 assert!(!caps.enforce_filesystem_policy);
548 assert!(caps.enforce_environment_policy);
549 }
550
551 #[test]
552 fn supports_only_rust() {
553 let executor = RustSandboxExecutor::default();
554 assert!(executor.supports_language(&ExecutionLanguage::Rust));
555 assert!(!executor.supports_language(&ExecutionLanguage::JavaScript));
556 assert!(!executor.supports_language(&ExecutionLanguage::Python));
557 assert!(!executor.supports_language(&ExecutionLanguage::Wasm));
558 assert!(!executor.supports_language(&ExecutionLanguage::Command));
559 }
560
561 #[test]
562 fn default_config() {
563 let config = RustSandboxConfig::default();
564 assert_eq!(config.rustc_path, "rustc");
565 assert!(config.rustc_flags.is_empty());
566 assert!(config.serde_json_path.is_none());
567 }
568}