Skip to main content

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