Skip to main content

adk_sandbox/
process.rs

1//! [`ProcessBackend`] — subprocess-based code execution via `tokio::process::Command`.
2//!
3//! This backend spawns child processes to execute code in various languages.
4//! It enforces timeout and environment isolation but does **not** enforce
5//! memory limits, network isolation, or filesystem isolation.
6//!
7//! # Supported Languages
8//!
9//! | Language   | Execution Strategy                                    |
10//! |------------|-------------------------------------------------------|
11//! | Rust       | Write to temp file → compile with `rustc` → run binary |
12//! | Python     | Write to temp file → run with `python3`               |
13//! | JavaScript | Write to temp file → run with `node`                  |
14//! | TypeScript | Write to temp file → run with `node` (same as JS)     |
15//! | Command    | Execute code as `sh -c "<code>"`                      |
16//! | Wasm       | Not supported — use `WasmBackend` instead            |
17//!
18//! # Example
19//!
20//! ```rust,ignore
21//! use adk_sandbox::{ProcessBackend, ExecRequest, Language, SandboxBackend};
22//! use std::time::Duration;
23//! use std::collections::HashMap;
24//!
25//! let backend = ProcessBackend::default();
26//! let request = ExecRequest {
27//!     language: Language::Python,
28//!     code: "print('hello')".to_string(),
29//!     stdin: None,
30//!     timeout: Duration::from_secs(30),
31//!     memory_limit_mb: None,
32//!     env: HashMap::new(),
33//! };
34//! let result = backend.execute(request).await?;
35//! assert_eq!(result.stdout.trim(), "hello");
36//! ```
37
38use std::ffi::{OsStr, OsString};
39use std::time::Instant;
40
41use async_trait::async_trait;
42use tokio::io::AsyncWriteExt;
43use tokio::process::Command;
44use tracing::{Span, instrument};
45
46use crate::backend::{BackendCapabilities, EnforcedLimits, SandboxBackend};
47use crate::error::SandboxError;
48use crate::sandbox::{SandboxEnforcer, SandboxPolicy};
49use crate::types::{ExecRequest, ExecResult, Language};
50
51/// Maximum output size in bytes (1 MB).
52const MAX_OUTPUT_BYTES: usize = 1_024 * 1_024;
53
54/// Configuration for [`ProcessBackend`].
55///
56/// Provides paths to language runtimes. Defaults use bare command names
57/// that rely on `PATH` resolution.
58///
59/// # Example
60///
61/// ```rust
62/// use adk_sandbox::ProcessConfig;
63///
64/// let config = ProcessConfig {
65///     rustc_path: "/usr/local/bin/rustc".to_string(),
66///     ..ProcessConfig::default()
67/// };
68/// ```
69#[derive(Debug, Clone)]
70pub struct ProcessConfig {
71    /// Path to the Rust compiler. Default: `"rustc"`.
72    pub rustc_path: String,
73    /// Path to the Python 3 interpreter. Default: `"python3"`.
74    pub python_path: String,
75    /// Path to the Node.js runtime. Default: `"node"`.
76    pub node_path: String,
77}
78
79impl Default for ProcessConfig {
80    fn default() -> Self {
81        Self {
82            rustc_path: "rustc".to_string(),
83            python_path: "python3".to_string(),
84            node_path: "node".to_string(),
85        }
86    }
87}
88
89/// Subprocess-based sandbox backend.
90///
91/// Executes code by spawning child processes with `tokio::process::Command`.
92/// Enforces timeout via `tokio::time::timeout` and environment isolation
93/// via `env_clear()`. Optionally enforces filesystem and network isolation
94/// when a [`SandboxEnforcer`] is configured via [`with_sandbox()`](Self::with_sandbox).
95///
96/// # Example
97///
98/// ```rust
99/// use adk_sandbox::{ProcessBackend, SandboxBackend};
100///
101/// let backend = ProcessBackend::default();
102/// assert_eq!(backend.name(), "process");
103/// ```
104///
105/// # With OS-level sandbox
106///
107/// ```rust,ignore
108/// use adk_sandbox::{ProcessBackend, ProcessConfig, SandboxPolicyBuilder, get_enforcer};
109///
110/// let enforcer = get_enforcer()?;
111/// let policy = SandboxPolicyBuilder::new()
112///     .allow_read("/usr/lib")
113///     .allow_read_write("/tmp/work")
114///     .build();
115///
116/// let backend = ProcessBackend::with_sandbox(
117///     ProcessConfig::default(),
118///     enforcer,
119///     policy,
120/// );
121/// assert!(backend.capabilities().enforced_limits.filesystem_isolation);
122/// ```
123pub struct ProcessBackend {
124    config: ProcessConfig,
125    enforcer: Option<Box<dyn SandboxEnforcer>>,
126    policy: Option<SandboxPolicy>,
127}
128
129impl ProcessBackend {
130    /// Creates a new `ProcessBackend` with the given configuration.
131    pub fn new(config: ProcessConfig) -> Self {
132        Self { config, enforcer: None, policy: None }
133    }
134
135    /// Creates a new `ProcessBackend` with OS-level sandbox enforcement.
136    ///
137    /// All executions through this backend will be sandboxed with the given
138    /// policy. The enforcer wraps commands with platform-specific restrictions
139    /// (Seatbelt on macOS, bubblewrap on Linux, AppContainer on Windows).
140    ///
141    /// If different tools need different policies, create multiple
142    /// `ProcessBackend` instances.
143    pub fn with_sandbox(
144        config: ProcessConfig,
145        enforcer: Box<dyn SandboxEnforcer>,
146        policy: SandboxPolicy,
147    ) -> Self {
148        Self { config, enforcer: Some(enforcer), policy: Some(policy) }
149    }
150}
151
152impl Default for ProcessBackend {
153    fn default() -> Self {
154        Self::new(ProcessConfig::default())
155    }
156}
157
158// ProcessBackend can't derive Debug because Box<dyn SandboxEnforcer> doesn't impl Debug.
159impl std::fmt::Debug for ProcessBackend {
160    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161        f.debug_struct("ProcessBackend")
162            .field("config", &self.config)
163            .field("enforcer", &self.enforcer.as_ref().map(|e| e.name()))
164            .field("policy", &self.policy)
165            .finish()
166    }
167}
168
169/// Truncates a byte buffer to at most `max_bytes`, ensuring the result is
170/// valid UTF-8 by backing off to the nearest char boundary.
171fn truncate_utf8(bytes: Vec<u8>, max_bytes: usize) -> String {
172    if bytes.len() <= max_bytes {
173        return String::from_utf8_lossy(&bytes).into_owned();
174    }
175    let truncated = &bytes[..max_bytes];
176    // Walk backwards to find a valid UTF-8 boundary.
177    let mut end = max_bytes;
178    while end > 0 && std::str::from_utf8(&truncated[..end]).is_err() {
179        end -= 1;
180    }
181    std::str::from_utf8(&bytes[..end]).unwrap_or("").to_string()
182}
183
184#[async_trait]
185impl SandboxBackend for ProcessBackend {
186    fn name(&self) -> &str {
187        "process"
188    }
189
190    fn capabilities(&self) -> BackendCapabilities {
191        let has_enforcer = self.enforcer.is_some();
192        let denies_network = self.policy.as_ref().is_some_and(|p| !p.allow_network);
193
194        BackendCapabilities {
195            supported_languages: vec![
196                Language::Rust,
197                Language::Python,
198                Language::JavaScript,
199                Language::TypeScript,
200                Language::Command,
201            ],
202            isolation_class: if has_enforcer {
203                "process+sandbox".to_string()
204            } else {
205                "process".to_string()
206            },
207            enforced_limits: EnforcedLimits {
208                timeout: true,
209                memory: false,
210                network_isolation: has_enforcer && denies_network,
211                filesystem_isolation: has_enforcer,
212                environment_isolation: true,
213            },
214        }
215    }
216
217    #[instrument(
218        skip_all,
219        fields(
220            backend = "process",
221            language = %request.language,
222            exit_code,
223            duration_ms,
224        )
225    )]
226    async fn execute(&self, request: ExecRequest) -> Result<ExecResult, SandboxError> {
227        if let Some(limit) = request.memory_limit_mb {
228            tracing::debug!(
229                memory_limit_mb = limit,
230                "memory limit not enforced by process backend"
231            );
232        }
233
234        match request.language {
235            Language::Rust => self.execute_rust(&request).await,
236            Language::Python => self.execute_python(&request).await,
237            Language::JavaScript | Language::TypeScript => self.execute_javascript(&request).await,
238            Language::Command => self.execute_command(&request).await,
239            Language::Wasm => Err(SandboxError::InvalidRequest(
240                "Wasm execution is not supported by ProcessBackend. Use WasmBackend instead."
241                    .to_string(),
242            )),
243        }
244    }
245}
246
247impl ProcessBackend {
248    /// Executes Rust code: write to temp file → compile with rustc → run binary.
249    async fn execute_rust(&self, request: &ExecRequest) -> Result<ExecResult, SandboxError> {
250        let dir = tempfile::tempdir()?;
251        let src_path = dir.path().join("main.rs");
252        let bin_path = dir.path().join("main");
253
254        std::fs::write(&src_path, &request.code)?;
255
256        // Compile step
257        let compile_output = {
258            let mut cmd = Command::new(&self.config.rustc_path);
259            cmd.arg(&src_path).arg("-o").arg(&bin_path).env_clear().kill_on_drop(true);
260            for (k, v) in &request.env {
261                cmd.env(k, v);
262            }
263            cmd.output().await?
264        };
265
266        if !compile_output.status.success() {
267            let stderr = truncate_utf8(compile_output.stderr, MAX_OUTPUT_BYTES);
268            let stdout = truncate_utf8(compile_output.stdout, MAX_OUTPUT_BYTES);
269            let exit_code = compile_output.status.code().unwrap_or(1);
270            let result =
271                ExecResult { stdout, stderr, exit_code, duration: std::time::Duration::ZERO };
272            Span::current().record("exit_code", exit_code);
273            Span::current().record("duration_ms", 0_u64);
274            return Ok(result);
275        }
276
277        // Run the compiled binary
278        self.run_binary(&bin_path, request).await
279    }
280
281    /// Executes Python code: write to temp file → run with python3.
282    async fn execute_python(&self, request: &ExecRequest) -> Result<ExecResult, SandboxError> {
283        let dir = tempfile::tempdir()?;
284        let src_path = dir.path().join("script.py");
285        std::fs::write(&src_path, &request.code)?;
286
287        let mut cmd = Command::new(&self.config.python_path);
288        cmd.arg(&src_path);
289        self.run_command(cmd, request).await
290    }
291
292    /// Executes JavaScript code: write to temp file → run with node.
293    async fn execute_javascript(&self, request: &ExecRequest) -> Result<ExecResult, SandboxError> {
294        let dir = tempfile::tempdir()?;
295        let src_path = dir.path().join("script.js");
296        std::fs::write(&src_path, &request.code)?;
297
298        let mut cmd = Command::new(&self.config.node_path);
299        cmd.arg(&src_path);
300        self.run_command(cmd, request).await
301    }
302
303    /// Executes a raw shell command via the platform shell.
304    async fn execute_command(&self, request: &ExecRequest) -> Result<ExecResult, SandboxError> {
305        let cmd = if cfg!(windows) {
306            let mut c = Command::new("cmd");
307            c.arg("/C").arg(&request.code);
308            c
309        } else {
310            let mut c = Command::new("sh");
311            c.arg("-c").arg(&request.code);
312            c
313        };
314        self.run_command(cmd, request).await
315    }
316
317    /// Runs a compiled binary with timeout, env isolation, and stdin piping.
318    async fn run_binary(
319        &self,
320        bin_path: &std::path::Path,
321        request: &ExecRequest,
322    ) -> Result<ExecResult, SandboxError> {
323        let cmd = Command::new(bin_path);
324        self.run_command(cmd, request).await
325    }
326
327    /// Shared execution logic: env isolation, stdin piping, timeout, output capture.
328    ///
329    /// When a [`SandboxEnforcer`] is configured, the command is wrapped with
330    /// platform-specific sandbox restrictions before spawning.
331    async fn run_command(
332        &self,
333        cmd: Command,
334        request: &ExecRequest,
335    ) -> Result<ExecResult, SandboxError> {
336        // If a sandbox enforcer is configured, wrap the command.
337        // We extract the program and args from the pre-built Command,
338        // pass them through the enforcer, and create a new Command.
339        let mut cmd = if let (Some(enforcer), Some(policy)) = (&self.enforcer, &self.policy) {
340            let std_cmd = cmd.as_std();
341            let program = std_cmd.get_program();
342            let args: Vec<OsString> = std_cmd.get_args().map(OsStr::to_owned).collect();
343
344            let wrapped = enforcer.wrap_command(program, &args, policy)?;
345
346            let mut new_cmd = Command::new(&wrapped.program);
347            new_cmd.args(&wrapped.args);
348
349            // Apply any post-construction configuration (e.g., Windows AppContainer)
350            enforcer.configure_command(&mut new_cmd, policy)?;
351
352            new_cmd
353        } else {
354            cmd
355        };
356
357        cmd.env_clear();
358        for (k, v) in &request.env {
359            cmd.env(k, v);
360        }
361        cmd.kill_on_drop(true);
362
363        cmd.stdout(std::process::Stdio::piped());
364        cmd.stderr(std::process::Stdio::piped());
365
366        if request.stdin.is_some() {
367            cmd.stdin(std::process::Stdio::piped());
368        } else {
369            cmd.stdin(std::process::Stdio::null());
370        }
371
372        let start = Instant::now();
373        let mut child = cmd.spawn()?;
374
375        // Pipe stdin if provided
376        if let Some(ref input) = request.stdin {
377            if let Some(mut stdin_handle) = child.stdin.take() {
378                stdin_handle.write_all(input.as_bytes()).await?;
379                drop(stdin_handle);
380            }
381        }
382
383        // Wait with timeout
384        let output = tokio::time::timeout(request.timeout, child.wait_with_output()).await;
385        let duration = start.elapsed();
386
387        match output {
388            Ok(Ok(output)) => {
389                let exit_code = output.status.code().unwrap_or(-1);
390                let stdout = truncate_utf8(output.stdout, MAX_OUTPUT_BYTES);
391                let stderr = truncate_utf8(output.stderr, MAX_OUTPUT_BYTES);
392
393                Span::current().record("exit_code", exit_code);
394                Span::current().record("duration_ms", duration.as_millis() as u64);
395
396                Ok(ExecResult { stdout, stderr, exit_code, duration })
397            }
398            Ok(Err(e)) => {
399                Err(SandboxError::ExecutionFailed(format!("failed to wait for child process: {e}")))
400            }
401            Err(_) => {
402                // Timeout — child is killed by kill_on_drop
403                Span::current().record("duration_ms", duration.as_millis() as u64);
404                Err(SandboxError::Timeout { timeout: request.timeout })
405            }
406        }
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use std::collections::HashMap;
414    use std::time::Duration;
415
416    fn make_request(language: Language, code: &str) -> ExecRequest {
417        let mut env = HashMap::new();
418        // ProcessBackend clears the environment (REQ-SBX-023), so tests that
419        // invoke interpreters by name need PATH to resolve them.
420        if let Ok(path) = std::env::var("PATH") {
421            env.insert("PATH".to_string(), path);
422        }
423        // Windows processes need SYSTEMROOT for DLL loading and basic operation.
424        if let Ok(sr) = std::env::var("SYSTEMROOT") {
425            env.insert("SYSTEMROOT".to_string(), sr);
426        }
427        ExecRequest {
428            language,
429            code: code.to_string(),
430            stdin: None,
431            timeout: Duration::from_secs(30),
432            memory_limit_mb: None,
433            env,
434        }
435    }
436
437    #[tokio::test]
438    async fn test_python_execution() {
439        let backend = ProcessBackend::default();
440        let request = make_request(Language::Python, "print('hello')");
441        let result = backend.execute(request).await.unwrap();
442        assert!(result.stdout.contains("hello"), "stdout: {}", result.stdout);
443        assert_eq!(result.exit_code, 0);
444    }
445
446    #[tokio::test]
447    async fn test_javascript_execution() {
448        // Skip if node is not available (e.g. minimal CI images)
449        if std::process::Command::new("node").arg("--version").output().is_err() {
450            eprintln!("skipping test_javascript_execution: node not found");
451            return;
452        }
453        let backend = ProcessBackend::default();
454        let request = make_request(Language::JavaScript, "console.log('hello')");
455        let result = backend.execute(request).await.unwrap();
456        assert!(result.stdout.contains("hello"), "stdout: {}", result.stdout);
457        assert_eq!(result.exit_code, 0);
458    }
459
460    #[tokio::test]
461    async fn test_command_execution() {
462        let backend = ProcessBackend::default();
463        let request = make_request(Language::Command, "echo hello");
464        let result = backend.execute(request).await.unwrap();
465        assert!(result.stdout.contains("hello"), "stdout: {}", result.stdout);
466        assert_eq!(result.exit_code, 0);
467    }
468
469    #[tokio::test]
470    async fn test_timeout_enforcement() {
471        let backend = ProcessBackend::default();
472        let code =
473            if cfg!(windows) { "ping -n 11 127.0.0.1".to_string() } else { "sleep 10".to_string() };
474        let mut request = make_request(Language::Command, &code);
475        request.timeout = Duration::from_secs(1);
476        let result = backend.execute(request).await;
477        assert!(
478            matches!(result, Err(SandboxError::Timeout { .. })),
479            "expected Timeout, got: {result:?}"
480        );
481    }
482
483    #[tokio::test]
484    #[cfg(not(windows))]
485    async fn test_environment_isolation() {
486        let backend = ProcessBackend::default();
487        let mut env = HashMap::new();
488        env.insert("MY_TEST_VAR".to_string(), "test_value".to_string());
489        let request = ExecRequest {
490            language: Language::Command,
491            // Use absolute path to env since PATH won't be set
492            code: "/usr/bin/env".to_string(),
493            stdin: None,
494            timeout: Duration::from_secs(10),
495            memory_limit_mb: None,
496            env,
497        };
498        let result = backend.execute(request).await.unwrap();
499        // The only env var should be MY_TEST_VAR
500        assert!(result.stdout.contains("MY_TEST_VAR=test_value"), "stdout: {}", result.stdout);
501        // Common inherited vars like HOME should NOT be present
502        assert!(
503            !result.stdout.contains("HOME="),
504            "HOME should not be inherited: {}",
505            result.stdout
506        );
507    }
508
509    #[tokio::test]
510    #[cfg(windows)]
511    async fn test_environment_isolation() {
512        let backend = ProcessBackend::default();
513        let mut env = HashMap::new();
514        env.insert("MY_TEST_VAR".to_string(), "test_value".to_string());
515        let request = ExecRequest {
516            language: Language::Command,
517            code: "set MY_TEST_VAR".to_string(),
518            stdin: None,
519            timeout: Duration::from_secs(10),
520            memory_limit_mb: None,
521            env,
522        };
523        let result = backend.execute(request).await.unwrap();
524        assert!(result.stdout.contains("MY_TEST_VAR=test_value"), "stdout: {}", result.stdout);
525    }
526
527    #[tokio::test]
528    async fn test_nonzero_exit_code() {
529        let backend = ProcessBackend::default();
530        let code = if cfg!(windows) { "exit /b 42" } else { "exit 42" };
531        let request = make_request(Language::Command, code);
532        let result = backend.execute(request).await.unwrap();
533        assert_eq!(result.exit_code, 42);
534    }
535
536    #[tokio::test]
537    async fn test_wasm_returns_invalid_request() {
538        let backend = ProcessBackend::default();
539        let request = make_request(Language::Wasm, "");
540        let result = backend.execute(request).await;
541        assert!(
542            matches!(result, Err(SandboxError::InvalidRequest(_))),
543            "expected InvalidRequest, got: {result:?}"
544        );
545    }
546
547    #[test]
548    fn test_truncate_utf8_within_limit() {
549        let data = "hello world".as_bytes().to_vec();
550        let result = truncate_utf8(data, 1024);
551        assert_eq!(result, "hello world");
552    }
553
554    #[test]
555    fn test_truncate_utf8_at_boundary() {
556        // Multi-byte UTF-8: "é" is 2 bytes (0xC3 0xA9)
557        let data = "café".as_bytes().to_vec(); // 5 bytes: c a f 0xC3 0xA9
558        // Truncate at 4 bytes — would split the "é"
559        let result = truncate_utf8(data, 4);
560        assert_eq!(result, "caf");
561    }
562
563    #[test]
564    fn test_capabilities() {
565        let backend = ProcessBackend::default();
566        let caps = backend.capabilities();
567        assert_eq!(caps.isolation_class, "process");
568        assert!(caps.enforced_limits.timeout);
569        assert!(caps.enforced_limits.environment_isolation);
570        assert!(!caps.enforced_limits.memory);
571        assert!(!caps.enforced_limits.network_isolation);
572        assert!(!caps.enforced_limits.filesystem_isolation);
573        assert!(caps.supported_languages.contains(&Language::Rust));
574        assert!(caps.supported_languages.contains(&Language::Python));
575        assert!(caps.supported_languages.contains(&Language::JavaScript));
576        assert!(caps.supported_languages.contains(&Language::TypeScript));
577        assert!(caps.supported_languages.contains(&Language::Command));
578        assert!(!caps.supported_languages.contains(&Language::Wasm));
579    }
580
581    #[test]
582    fn test_name() {
583        let backend = ProcessBackend::default();
584        assert_eq!(backend.name(), "process");
585    }
586
587    #[test]
588    fn test_process_config_default() {
589        let config = ProcessConfig::default();
590        assert_eq!(config.rustc_path, "rustc");
591        assert_eq!(config.python_path, "python3");
592        assert_eq!(config.node_path, "node");
593    }
594}