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`), 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}