Skip to main content

adk_code/
container.rs

1//! Container code executor — persistent Docker-backed execution environment.
2//!
3//! [`DockerExecutor`] manages a persistent Docker container that survives across
4//! multiple [`execute`](CodeExecutor::execute) calls. Code is written to files
5//! inside the container's working directory and executed via `docker exec`,
6//! matching the lifecycle model of AutoGen's `DockerCommandLineCodeExecutor`.
7//!
8//! # Lifecycle
9//!
10//! ```text
11//! DockerExecutor::new(config)
12//!     │
13//!     ▼
14//!   start()  ──► creates & starts container, runs setup commands
15//!     │
16//!     ▼
17//!   execute() ──► writes code to file, exec's inside running container
18//!   execute() ──► reuses same container, accumulates workspace state
19//!   execute() ──► ...
20//!     │
21//!     ▼
22//!   stop()   ──► stops & removes container
23//! ```
24//!
25//! # Isolation Model
26//!
27//! The container's network mode, bind mounts, and environment are fixed by
28//! [`DockerConfig`] when it is created. Each request's [`SandboxPolicy`] is
29//! checked against them with [`DockerConfig::validate_sandbox_policy`], and a
30//! request stricter than the container is rejected before any code runs.
31//!
32//! | Capability | Enforced | Mechanism |
33//! |---|---|---|
34//! | Network policy | Yes | `NetworkPolicy::Disabled` requires a container created with network mode `none` |
35//! | Filesystem policy | Yes | Every bind mount must lie within the paths the policy grants |
36//! | Environment policy | Yes | Container variables must be on the allowlist; allowlisted host variables are passed to the exec |
37//! | Timeout | Yes | `tokio::time::timeout` on exec |
38//! | Structured output | Yes | Last-line JSON extraction from stdout |
39//! | Persistent workspace | Yes | Container survives across executions |
40//! | Interactive sessions | No | Each execute is a separate exec |
41//!
42//! # Example
43//!
44//! ```rust,ignore
45//! # async fn example() -> Result<(), adk_code::ExecutionError> {
46//! use adk_code::{
47//!     CodeExecutor, DockerExecutor, DockerConfig,
48//!     ExecutionLanguage, ExecutionPayload, ExecutionRequest, SandboxPolicy,
49//! };
50//!
51//! let executor = DockerExecutor::new(DockerConfig::python())?;
52//! executor.start().await?;
53//!
54//! let result = executor.execute(ExecutionRequest {
55//!     language: ExecutionLanguage::Python,
56//!     payload: ExecutionPayload::Source {
57//!         code: "print('hello from persistent container')".to_string(),
58//!     },
59//!     argv: vec![],
60//!     stdin: None,
61//!     input: None,
62//!     sandbox: SandboxPolicy::strict_rust(),
63//!     identity: None,
64//! }).await?;
65//!
66//! // Container is still running — next execute reuses it
67//! executor.stop().await?;
68//! # Ok(())
69//! # }
70//! ```
71
72use std::path::{Component, Path, PathBuf};
73use std::process::Stdio;
74use std::time::Instant;
75
76use async_trait::async_trait;
77use tracing::{debug, info, warn};
78
79use crate::host_process;
80use crate::{
81    BackendCapabilities, CodeExecutor, EnvironmentPolicy, ExecutionError, ExecutionIsolation,
82    ExecutionLanguage, ExecutionPayload, ExecutionRequest, ExecutionResult, ExecutionStatus,
83    FilesystemPolicy, NetworkPolicy, SandboxPolicy, validate_request,
84};
85
86/// Configuration for the Docker executor.
87///
88/// # Example
89///
90/// ```rust
91/// use adk_code::DockerConfig;
92///
93/// let config = DockerConfig::python();
94/// assert_eq!(config.image, "python:3.12-slim");
95/// assert_eq!(config.work_dir, "/workspace");
96/// ```
97#[derive(Debug, Clone)]
98pub struct DockerConfig {
99    /// Container image to use.
100    pub image: String,
101    /// Working directory inside the container.
102    pub work_dir: String,
103    /// Setup commands to run after container creation (e.g., `pip install`).
104    pub setup_commands: Vec<String>,
105    /// Extra environment variables to set in the container.
106    pub environment: Vec<String>,
107    /// Bind mounts in `host:container[:ro]` format.
108    pub bind_mounts: Vec<String>,
109    /// Whether to disable network access.
110    pub network_disabled: bool,
111    /// Container name prefix (a random suffix is appended).
112    pub container_name_prefix: String,
113    /// Whether to auto-start on first execute if not already running.
114    pub auto_start: bool,
115    /// Whether to auto-stop and remove the container on drop.
116    pub auto_remove: bool,
117}
118
119impl DockerConfig {
120    /// Python 3.12 preset.
121    pub fn python() -> Self {
122        Self {
123            image: "python:3.12-slim".to_string(),
124            work_dir: "/workspace".to_string(),
125            setup_commands: vec![],
126            environment: vec![],
127            bind_mounts: vec![],
128            network_disabled: true,
129            container_name_prefix: "adk-python".to_string(),
130            auto_start: true,
131            auto_remove: true,
132        }
133    }
134
135    /// Node.js 20 preset.
136    pub fn node() -> Self {
137        Self {
138            image: "node:20-slim".to_string(),
139            work_dir: "/workspace".to_string(),
140            setup_commands: vec![],
141            environment: vec![],
142            bind_mounts: vec![],
143            network_disabled: true,
144            container_name_prefix: "adk-node".to_string(),
145            auto_start: true,
146            auto_remove: true,
147        }
148    }
149
150    /// Custom image preset.
151    pub fn custom(image: impl Into<String>) -> Self {
152        Self {
153            image: image.into(),
154            work_dir: "/workspace".to_string(),
155            setup_commands: vec![],
156            environment: vec![],
157            bind_mounts: vec![],
158            network_disabled: true,
159            container_name_prefix: "adk-custom".to_string(),
160            auto_start: true,
161            auto_remove: true,
162        }
163    }
164
165    /// Add a setup command to run after container creation.
166    pub fn setup_command(mut self, cmd: impl Into<String>) -> Self {
167        self.setup_commands.push(cmd.into());
168        self
169    }
170
171    /// Add a pip install command for Python dependencies.
172    pub fn pip_install(self, packages: &[&str]) -> Self {
173        self.setup_command(format!("pip install --quiet {}", packages.join(" ")))
174    }
175
176    /// Add an npm install command for Node.js dependencies.
177    pub fn npm_install(self, packages: &[&str]) -> Self {
178        self.setup_command(format!("npm install --silent {}", packages.join(" ")))
179    }
180
181    /// Enable network access (disabled by default).
182    pub fn with_network(mut self) -> Self {
183        self.network_disabled = false;
184        self
185    }
186
187    /// Add a bind mount.
188    pub fn bind_mount(mut self, mount: impl Into<String>) -> Self {
189        self.bind_mounts.push(mount.into());
190        self
191    }
192
193    /// Add an environment variable.
194    pub fn env(mut self, var: impl Into<String>) -> Self {
195        self.environment.push(var.into());
196        self
197    }
198
199    /// Checks that a container created from this configuration satisfies `policy`.
200    ///
201    /// A persistent container's network mode, bind mounts, and environment are
202    /// fixed when it is created, so a request cannot narrow them afterwards.
203    /// `DockerExecutor` calls this before every execution and rejects a request
204    /// whose policy is stricter than the container:
205    ///
206    /// | Policy | Satisfied when |
207    /// |---|---|
208    /// | `NetworkPolicy::Disabled` | `network_disabled` is `true` |
209    /// | `FilesystemPolicy::None` | there are no bind mounts |
210    /// | `FilesystemPolicy::WorkspaceReadOnly { root }` | every bind mount is read-only (`:ro`) and its host path lies under `root` |
211    /// | `FilesystemPolicy::WorkspaceReadWrite { root }` | every bind mount's host path lies under `root` |
212    /// | `FilesystemPolicy::Paths { read_only, read_write }` | every bind mount lies under a `read_write` path, or is read-only and lies under a `read_only` path |
213    /// | `EnvironmentPolicy::None` | `environment` is empty |
214    /// | `EnvironmentPolicy::AllowList(names)` | every `environment` entry's name is in `names` |
215    ///
216    /// Bind-mount host paths must be absolute and free of `..` components, so a
217    /// named volume never satisfies a filesystem policy. Variables defined by the
218    /// image itself (`PATH`, `LANG`) come from the image, not the host, and are
219    /// not checked.
220    ///
221    /// # Errors
222    ///
223    /// Returns [`ExecutionError::UnsupportedPolicy`] naming the first setting
224    /// that exceeds the policy.
225    ///
226    /// # Example
227    ///
228    /// ```rust
229    /// use adk_code::{DockerConfig, NetworkPolicy, SandboxPolicy};
230    ///
231    /// let strict = SandboxPolicy::strict_rust();
232    /// assert!(DockerConfig::python().validate_sandbox_policy(&strict).is_ok());
233    ///
234    /// // A networked container cannot honour a request that disables the network.
235    /// let networked = DockerConfig::python().with_network();
236    /// assert!(networked.validate_sandbox_policy(&strict).is_err());
237    ///
238    /// let allows_network = SandboxPolicy { network: NetworkPolicy::Enabled, ..strict };
239    /// assert!(networked.validate_sandbox_policy(&allows_network).is_ok());
240    /// ```
241    pub fn validate_sandbox_policy(&self, policy: &SandboxPolicy) -> Result<(), ExecutionError> {
242        if matches!(policy.network, NetworkPolicy::Disabled) && !self.network_disabled {
243            return Err(ExecutionError::UnsupportedPolicy(
244                "the policy disables network access, but the container was created with network \
245                 access; drop `with_network()` from the DockerConfig or use \
246                 `NetworkPolicy::Enabled`"
247                    .to_string(),
248            ));
249        }
250
251        for mount in &self.bind_mounts {
252            // `host:container[:options]`; a Windows drive letter adds a colon to the host part.
253            let parts: Vec<&str> = mount.split(':').collect();
254            let has_drive_letter = parts.len() > 2
255                && parts[0].len() == 1
256                && parts[0].chars().all(|c| c.is_ascii_alphabetic());
257            let (host, rest) = if has_drive_letter {
258                (&mount[..parts[0].len() + 1 + parts[1].len()], &parts[2..])
259            } else {
260                (parts[0], &parts[1..])
261            };
262            let read_only = rest
263                .get(1)
264                .is_some_and(|options| options.split(',').any(|o| o == "ro" || o == "readonly"));
265
266            // `has_root` rather than `is_absolute`: on Windows a Linux-container mount such as
267            // `/srv/data` is rooted but not absolute, and containment only needs the path to be
268            // anchored and free of `..` for `starts_with` against the policy root to hold.
269            let host = Path::new(host);
270            let confined = host.has_root()
271                && !host.components().any(|component| matches!(component, Component::ParentDir));
272            let under = |root: &PathBuf| confined && host.starts_with(root);
273
274            let permitted = match &policy.filesystem {
275                FilesystemPolicy::None => false,
276                FilesystemPolicy::WorkspaceReadOnly { root } => read_only && under(root),
277                FilesystemPolicy::WorkspaceReadWrite { root } => under(root),
278                FilesystemPolicy::Paths { read_only: read_only_paths, read_write } => {
279                    read_write.iter().any(under) || (read_only && read_only_paths.iter().any(under))
280                }
281            };
282            if !permitted {
283                return Err(ExecutionError::UnsupportedPolicy(format!(
284                    "bind mount `{mount}` exceeds the filesystem policy {:?}; remove the mount \
285                     or grant its host path in the policy",
286                    policy.filesystem
287                )));
288            }
289        }
290
291        for entry in &self.environment {
292            let name = entry.split_once('=').map_or(entry.as_str(), |(name, _)| name);
293            let allowed = match &policy.environment {
294                EnvironmentPolicy::None => false,
295                EnvironmentPolicy::AllowList(names) => names.iter().any(|allowed| allowed == name),
296            };
297            if !allowed {
298                return Err(ExecutionError::UnsupportedPolicy(format!(
299                    "container environment variable `{name}` is not allowed by the environment \
300                     policy; remove it from the DockerConfig or add it to \
301                     `EnvironmentPolicy::AllowList`"
302                )));
303            }
304        }
305
306        Ok(())
307    }
308}
309
310impl Default for DockerConfig {
311    fn default() -> Self {
312        Self::python()
313    }
314}
315
316// ── Docker SDK implementation (behind `docker` feature) ────────────────
317
318#[cfg(feature = "docker")]
319mod docker_impl {
320    use super::*;
321    use bollard::Docker;
322    use bollard::container::{
323        Config, CreateContainerOptions, RemoveContainerOptions, StartContainerOptions,
324    };
325    use bollard::exec::{CreateExecOptions, StartExecResults};
326    use futures::StreamExt;
327    use rand::Rng;
328    use tokio::sync::RwLock;
329
330    /// State of the managed container.
331    #[derive(Debug)]
332    struct ContainerState {
333        /// Docker container ID.
334        id: String,
335        /// Whether the container is currently running.
336        running: bool,
337        /// Counter for generating unique filenames.
338        file_counter: u64,
339    }
340
341    /// Persistent Docker-backed code execution environment.
342    ///
343    /// Creates a container once via [`start`](CodeExecutor::start), keeps it running,
344    /// and executes code via `docker exec` inside the running container. This matches
345    /// the lifecycle model of AutoGen's `DockerCommandLineCodeExecutor`.
346    ///
347    /// # Sandbox Policy
348    ///
349    /// The container's network mode, bind mounts, and environment come from
350    /// [`DockerConfig`] and cannot change per request. [`execute`](CodeExecutor::execute)
351    /// rejects with [`ExecutionError::UnsupportedPolicy`] any request whose policy
352    /// the container does not satisfy; see [`DockerConfig::validate_sandbox_policy`].
353    /// Variables named by an [`EnvironmentPolicy::AllowList`] are passed from the
354    /// host to that request's exec.
355    ///
356    /// # Cleanup
357    ///
358    /// Call [`cleanup()`](Self::cleanup) explicitly before dropping to ensure
359    /// reliable container removal. The [`Drop`] implementation is best-effort
360    /// and requires a tokio runtime to be available.
361    ///
362    /// # Example
363    ///
364    /// ```rust,no_run
365    /// # async fn example() -> Result<(), adk_code::ExecutionError> {
366    /// use adk_code::{CodeExecutor, DockerExecutor, DockerConfig};
367    ///
368    /// let executor = DockerExecutor::new(DockerConfig::python())?;
369    /// executor.start().await?;
370    /// assert!(executor.is_running().await);
371    ///
372    /// // Prefer explicit cleanup over relying on Drop
373    /// executor.cleanup().await?;
374    /// # Ok(())
375    /// # }
376    /// ```
377    pub struct DockerExecutor {
378        config: DockerConfig,
379        docker: Docker,
380        state: RwLock<Option<ContainerState>>,
381    }
382
383    impl std::fmt::Debug for DockerExecutor {
384        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
385            f.debug_struct("DockerExecutor").field("config", &self.config).finish()
386        }
387    }
388
389    impl DockerExecutor {
390        /// Create a new Docker executor with the given configuration.
391        ///
392        /// This does NOT start the container — call [`start`](CodeExecutor::start)
393        /// or set `auto_start: true` (the default) to have it start on first execute.
394        /// # Errors
395        ///
396        /// Returns [`ExecutionError::InternalError`] if the Docker daemon is
397        /// unreachable (not installed, not running, or socket permission denied).
398        pub fn new(config: DockerConfig) -> std::result::Result<Self, ExecutionError> {
399            let docker = Docker::connect_with_local_defaults().map_err(|e| {
400                ExecutionError::InternalError(format!(
401                    "failed to connect to Docker daemon: {e}. Is Docker installed and running?"
402                ))
403            })?;
404            Ok(Self { config, docker, state: RwLock::new(None) })
405        }
406
407        /// Create with a custom Docker connection.
408        pub fn with_docker(config: DockerConfig, docker: Docker) -> Self {
409            Self { config, docker, state: RwLock::new(None) }
410        }
411
412        /// Explicitly stop and remove the Docker container.
413        ///
414        /// Prefer calling this method before dropping the executor to ensure
415        /// reliable cleanup. The [`Drop`] implementation is best-effort and may
416        /// not work if no tokio runtime is available.
417        pub async fn cleanup(&self) -> Result<(), ExecutionError> {
418            let mut state = self.state.write().await;
419            if let Some(s) = state.take() {
420                info!(container_id = %s.id, "cleaning up container");
421                self.docker
422                    .remove_container(
423                        &s.id,
424                        Some(RemoveContainerOptions { force: true, ..Default::default() }),
425                    )
426                    .await
427                    .map_err(|e| {
428                        ExecutionError::ExecutionFailed(format!("failed to remove container: {e}"))
429                    })?;
430            }
431            Ok(())
432        }
433
434        /// Generate a unique container name.
435        fn container_name(&self) -> String {
436            let suffix: u32 = rand::rng().random_range(100_000..999_999);
437            format!("{}-{suffix}", self.config.container_name_prefix)
438        }
439
440        /// Get the file extension for a language.
441        fn file_extension(lang: &ExecutionLanguage) -> &'static str {
442            match lang {
443                ExecutionLanguage::Python => "py",
444                ExecutionLanguage::JavaScript => "js",
445                ExecutionLanguage::Rust => "rs",
446                ExecutionLanguage::Command => "sh",
447                ExecutionLanguage::Wasm => "wasm",
448            }
449        }
450
451        /// Get the execution command for a language and filename.
452        fn exec_command(lang: &ExecutionLanguage, filename: &str) -> Vec<String> {
453            match lang {
454                ExecutionLanguage::Python => {
455                    vec!["python3".to_string(), filename.to_string()]
456                }
457                ExecutionLanguage::JavaScript => {
458                    vec!["node".to_string(), filename.to_string()]
459                }
460                ExecutionLanguage::Command => {
461                    vec!["sh".to_string(), filename.to_string()]
462                }
463                _ => vec![],
464            }
465        }
466
467        /// Write a file inside the running container.
468        async fn write_file(
469            &self,
470            container_id: &str,
471            path: &str,
472            content: &str,
473        ) -> Result<(), ExecutionError> {
474            // Use a heredoc-style approach to write file content safely.
475            // Base64 encode to avoid shell escaping issues.
476            let encoded = base64_encode(content.as_bytes());
477            let cmd = vec![
478                "sh".to_string(),
479                "-c".to_string(),
480                format!("echo '{encoded}' | base64 -d > {path}"),
481            ];
482            self.exec_in_container(container_id, &cmd, None, None).await?;
483            Ok(())
484        }
485
486        /// Execute a command inside the running container and capture output.
487        ///
488        /// `env` entries (`KEY=VALUE`) are added to the container's own environment
489        /// for this exec only.
490        async fn exec_in_container(
491            &self,
492            container_id: &str,
493            cmd: &[String],
494            env: Option<Vec<String>>,
495            timeout: Option<std::time::Duration>,
496        ) -> Result<(String, String, Option<i64>), ExecutionError> {
497            let exec = self
498                .docker
499                .create_exec(
500                    container_id,
501                    CreateExecOptions {
502                        cmd: Some(cmd.to_vec()),
503                        env,
504                        attach_stdout: Some(true),
505                        attach_stderr: Some(true),
506                        working_dir: Some(self.config.work_dir.clone()),
507                        ..Default::default()
508                    },
509                )
510                .await
511                .map_err(|e| {
512                    ExecutionError::ExecutionFailed(format!("failed to create exec: {e}"))
513                })?;
514
515            let exec_output = async {
516                match self.docker.start_exec(&exec.id, None).await {
517                    Ok(StartExecResults::Attached { mut output, .. }) => {
518                        let mut stdout = String::new();
519                        let mut stderr = String::new();
520
521                        while let Some(chunk) = output.next().await {
522                            match chunk {
523                                Ok(bollard::container::LogOutput::StdOut { message }) => {
524                                    stdout.push_str(&String::from_utf8_lossy(&message));
525                                }
526                                Ok(bollard::container::LogOutput::StdErr { message }) => {
527                                    stderr.push_str(&String::from_utf8_lossy(&message));
528                                }
529                                Ok(_) => {}
530                                Err(e) => {
531                                    return Err(ExecutionError::ExecutionFailed(format!(
532                                        "exec stream error: {e}"
533                                    )));
534                                }
535                            }
536                        }
537
538                        // Get exit code.
539                        let inspect = self.docker.inspect_exec(&exec.id).await.map_err(|e| {
540                            ExecutionError::ExecutionFailed(format!("failed to inspect exec: {e}"))
541                        })?;
542                        let exit_code = inspect.exit_code;
543
544                        Ok((stdout, stderr, exit_code))
545                    }
546                    Ok(StartExecResults::Detached) => Ok((String::new(), String::new(), None)),
547                    Err(e) => {
548                        Err(ExecutionError::ExecutionFailed(format!("failed to start exec: {e}")))
549                    }
550                }
551            };
552
553            if let Some(dur) = timeout {
554                match tokio::time::timeout(dur, exec_output).await {
555                    Ok(result) => result,
556                    Err(_) => Err(ExecutionError::Timeout(dur.as_millis() as u64)),
557                }
558            } else {
559                exec_output.await
560            }
561        }
562    }
563
564    #[async_trait]
565    impl CodeExecutor for DockerExecutor {
566        fn name(&self) -> &str {
567            "docker"
568        }
569
570        fn capabilities(&self) -> BackendCapabilities {
571            BackendCapabilities {
572                isolation: ExecutionIsolation::ContainerPersistent,
573                enforce_network_policy: true,
574                enforce_filesystem_policy: true,
575                enforce_environment_policy: true,
576                enforce_timeout: true,
577                supports_structured_output: true,
578                supports_process_execution: true,
579                supports_persistent_workspace: true,
580                supports_interactive_sessions: false,
581            }
582        }
583
584        fn supports_language(&self, lang: &ExecutionLanguage) -> bool {
585            matches!(
586                lang,
587                ExecutionLanguage::Python
588                    | ExecutionLanguage::JavaScript
589                    | ExecutionLanguage::Command
590            )
591        }
592
593        async fn start(&self) -> Result<(), ExecutionError> {
594            let mut state = self.state.write().await;
595            if state.as_ref().is_some_and(|s| s.running) {
596                return Ok(());
597            }
598
599            let name = self.container_name();
600            info!(image = %self.config.image, container = %name, "creating container");
601
602            // Build container config.
603            let mut host_config = bollard::models::HostConfig::default();
604
605            if self.config.network_disabled {
606                host_config.network_mode = Some("none".to_string());
607            }
608
609            if !self.config.bind_mounts.is_empty() {
610                host_config.binds = Some(self.config.bind_mounts.clone());
611            }
612
613            let env = if self.config.environment.is_empty() {
614                None
615            } else {
616                Some(self.config.environment.clone())
617            };
618
619            let container_config = Config {
620                image: Some(self.config.image.clone()),
621                working_dir: Some(self.config.work_dir.clone()),
622                env,
623                host_config: Some(host_config),
624                // Keep container alive with a long sleep.
625                cmd: Some(vec!["sleep".to_string(), "infinity".to_string()]),
626                tty: Some(false),
627                ..Default::default()
628            };
629
630            let create_opts = CreateContainerOptions { name: name.clone(), ..Default::default() };
631
632            let response =
633                self.docker.create_container(Some(create_opts), container_config).await.map_err(
634                    |e| ExecutionError::ExecutionFailed(format!("failed to create container: {e}")),
635                )?;
636
637            let container_id = response.id;
638            debug!(container_id = %container_id, "container created");
639
640            // Start the container.
641            self.docker
642                .start_container(&container_id, None::<StartContainerOptions<String>>)
643                .await
644                .map_err(|e| {
645                    ExecutionError::ExecutionFailed(format!("failed to start container: {e}"))
646                })?;
647
648            info!(container_id = %container_id, "container started");
649
650            // Create workspace directory.
651            let mkdir_cmd =
652                vec!["mkdir".to_string(), "-p".to_string(), self.config.work_dir.clone()];
653            let _ = self.exec_in_container(&container_id, &mkdir_cmd, None, None).await;
654
655            // Run setup commands.
656            for setup_cmd in &self.config.setup_commands {
657                info!(cmd = %setup_cmd, "running setup command");
658                let cmd = vec!["sh".to_string(), "-c".to_string(), setup_cmd.clone()];
659                let (_stdout, stderr, exit_code) =
660                    self.exec_in_container(&container_id, &cmd, None, None).await?;
661
662                if exit_code != Some(0) {
663                    warn!(
664                        exit_code = ?exit_code,
665                        stderr = %stderr,
666                        "setup command failed"
667                    );
668                    // Clean up on setup failure.
669                    let _ = self
670                        .docker
671                        .remove_container(
672                            &container_id,
673                            Some(RemoveContainerOptions { force: true, ..Default::default() }),
674                        )
675                        .await;
676                    return Err(ExecutionError::ExecutionFailed(format!(
677                        "setup command failed: {setup_cmd}\nstderr: {stderr}"
678                    )));
679                }
680            }
681
682            *state = Some(ContainerState { id: container_id, running: true, file_counter: 0 });
683
684            Ok(())
685        }
686
687        async fn stop(&self) -> Result<(), ExecutionError> {
688            let mut state = self.state.write().await;
689            if let Some(s) = state.take() {
690                info!(container_id = %s.id, "stopping container");
691                let _ = self
692                    .docker
693                    .remove_container(
694                        &s.id,
695                        Some(RemoveContainerOptions { force: true, ..Default::default() }),
696                    )
697                    .await;
698            }
699            Ok(())
700        }
701
702        async fn is_running(&self) -> bool {
703            self.state.read().await.as_ref().is_some_and(|s| s.running)
704        }
705
706        async fn execute(
707            &self,
708            request: ExecutionRequest,
709        ) -> Result<ExecutionResult, ExecutionError> {
710            let supported = [
711                ExecutionLanguage::Python,
712                ExecutionLanguage::JavaScript,
713                ExecutionLanguage::Command,
714            ];
715            validate_request(&self.capabilities(), &supported, &request)?;
716            self.config.validate_sandbox_policy(&request.sandbox)?;
717
718            let code = match &request.payload {
719                ExecutionPayload::Source { code } if code.trim().is_empty() => {
720                    return Err(ExecutionError::InvalidRequest("empty source code".to_string()));
721                }
722                ExecutionPayload::Source { code } => code.clone(),
723                ExecutionPayload::GuestModule { .. } => {
724                    return Err(ExecutionError::InvalidRequest(
725                        "DockerExecutor does not support guest modules".to_string(),
726                    ));
727                }
728            };
729
730            // Auto-start if configured and not running.
731            if self.config.auto_start && !self.is_running().await {
732                self.start().await?;
733            }
734
735            // Get container ID and increment file counter.
736            let (container_id, filename) = {
737                let mut state = self.state.write().await;
738                let s = state.as_mut().ok_or_else(|| {
739                    ExecutionError::ExecutionFailed(
740                        "container not started — call start() first".to_string(),
741                    )
742                })?;
743                s.file_counter += 1;
744                let ext = Self::file_extension(&request.language);
745                let filename = format!("{}/code_{}.{ext}", self.config.work_dir, s.file_counter);
746                (s.id.clone(), filename)
747            };
748
749            let start = Instant::now();
750
751            // Write code to file inside container.
752            self.write_file(&container_id, &filename, &code).await?;
753
754            // If there's structured input, write it as a JSON file.
755            if let Some(ref input) = request.input {
756                let input_json = serde_json::to_string(input).unwrap_or_default();
757                let input_path = format!("{}/input.json", self.config.work_dir);
758                self.write_file(&container_id, &input_path, &input_json).await?;
759            }
760
761            // Build execution command.
762            let exec_cmd = Self::exec_command(&request.language, &filename);
763            if exec_cmd.is_empty() {
764                return Err(ExecutionError::UnsupportedLanguage(format!("{}", request.language)));
765            }
766
767            debug!(
768                container_id = %container_id,
769                language = %request.language,
770                filename = %filename,
771                "executing code in container"
772            );
773
774            // Mirrors `docker run --env NAME`: allowlisted variables carry the host's values.
775            let exec_env = host_process::allowlisted_host_env(&request.sandbox.environment)
776                .into_iter()
777                .map(|(name, value)| format!("{name}={}", value.to_string_lossy()))
778                .collect::<Vec<_>>();
779
780            // Execute with timeout.
781            let (stdout, stderr, exit_code) = self
782                .exec_in_container(
783                    &container_id,
784                    &exec_cmd,
785                    Some(exec_env),
786                    Some(request.sandbox.timeout),
787                )
788                .await?;
789
790            let duration_ms = start.elapsed().as_millis() as u64;
791
792            let (stdout, stdout_truncated) =
793                truncate_output(stdout, request.sandbox.max_stdout_bytes);
794            let (stderr, stderr_truncated) =
795                truncate_output(stderr, request.sandbox.max_stderr_bytes);
796
797            let (structured_output, display_stdout) = extract_structured_output(&stdout);
798
799            let status = match exit_code {
800                Some(0) => ExecutionStatus::Success,
801                _ => ExecutionStatus::Failed,
802            };
803
804            info!(
805                exit_code = ?exit_code,
806                duration_ms,
807                has_structured_output = structured_output.is_some(),
808                "container execution completed"
809            );
810
811            Ok(ExecutionResult {
812                status,
813                stdout: display_stdout,
814                stderr,
815                output: structured_output,
816                exit_code: exit_code.map(|c| c as i32),
817                stdout_truncated,
818                stderr_truncated,
819                duration_ms,
820                metadata: None,
821            })
822        }
823    }
824
825    impl Drop for DockerExecutor {
826        fn drop(&mut self) {
827            if self.config.auto_remove {
828                // Best-effort cleanup — we can't await in drop, so spawn a task
829                // only if a tokio runtime is available.
830                if let Some(state) = self.state.get_mut().take() {
831                    let docker = self.docker.clone();
832                    let container_id = state.id;
833                    match tokio::runtime::Handle::try_current() {
834                        Ok(handle) => {
835                            handle.spawn(async move {
836                                let _ = docker
837                                    .remove_container(
838                                        &container_id,
839                                        Some(RemoveContainerOptions {
840                                            force: true,
841                                            ..Default::default()
842                                        }),
843                                    )
844                                    .await;
845                            });
846                        }
847                        Err(_) => {
848                            tracing::warn!(
849                                container_id = %container_id,
850                                "no tokio runtime available during DockerExecutor drop, \
851                                 container may leak. Call cleanup() explicitly before dropping."
852                            );
853                        }
854                    }
855                }
856            }
857        }
858    }
859
860    /// Simple base64 encoder (no padding issues with shell).
861    fn base64_encode(data: &[u8]) -> String {
862        const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
863        let mut result = String::with_capacity(data.len().div_ceil(3) * 4);
864        for chunk in data.chunks(3) {
865            let b0 = chunk[0] as u32;
866            let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
867            let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
868            let triple = (b0 << 16) | (b1 << 8) | b2;
869            result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
870            result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
871            if chunk.len() > 1 {
872                result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
873            } else {
874                result.push('=');
875            }
876            if chunk.len() > 2 {
877                result.push(CHARS[(triple & 0x3F) as usize] as char);
878            } else {
879                result.push('=');
880            }
881        }
882        result
883    }
884}
885
886#[cfg(feature = "docker")]
887pub use docker_impl::DockerExecutor;
888
889// ── CLI-based fallback (always available) ──────────────────────────────
890
891/// Configuration for the CLI-based container command executor.
892///
893/// This is the fallback executor that shells out to `docker run` for each
894/// execution. For production use, prefer `DockerExecutor` (behind the
895/// `docker` feature) which uses persistent containers.
896///
897/// # Example
898///
899/// ```rust
900/// use adk_code::ContainerConfig;
901///
902/// let config = ContainerConfig::default();
903/// assert_eq!(config.runtime, "docker");
904/// ```
905#[derive(Debug, Clone)]
906pub struct ContainerConfig {
907    /// Container runtime binary (e.g., `"docker"`, `"podman"`).
908    pub runtime: String,
909    /// Default container image when not overridden per-request.
910    pub default_image: String,
911    /// Extra flags passed to the container runtime `run` command.
912    pub extra_flags: Vec<String>,
913    /// Whether to automatically remove the container after execution.
914    pub auto_remove: bool,
915}
916
917impl Default for ContainerConfig {
918    fn default() -> Self {
919        Self {
920            runtime: "docker".to_string(),
921            default_image: "python:3.12-slim".to_string(),
922            extra_flags: vec![],
923            auto_remove: true,
924        }
925    }
926}
927
928/// CLI-based container executor that shells out to `docker run` per execution.
929///
930/// Each [`execute`](CodeExecutor::execute) call spawns a new ephemeral container.
931/// This is simpler but less efficient than `DockerExecutor` which reuses a
932/// persistent container.
933///
934/// For production use, prefer `DockerExecutor` (behind the `docker` feature).
935///
936/// # Example
937///
938/// ```rust
939/// use adk_code::{CodeExecutor, ContainerCommandExecutor, ContainerConfig, ExecutionIsolation};
940///
941/// let executor = ContainerCommandExecutor::default();
942/// assert_eq!(executor.name(), "container-command");
943/// assert_eq!(executor.capabilities().isolation, ExecutionIsolation::ContainerEphemeral);
944/// ```
945#[derive(Debug, Clone)]
946pub struct ContainerCommandExecutor {
947    config: ContainerConfig,
948}
949
950impl ContainerCommandExecutor {
951    /// Create a new container command executor with the given configuration.
952    pub fn new(config: ContainerConfig) -> Self {
953        Self { config }
954    }
955
956    /// Build the container `run` command arguments for a given request.
957    fn build_run_args(&self, request: &ExecutionRequest) -> Vec<String> {
958        let mut args = vec!["run".to_string()];
959
960        if self.config.auto_remove {
961            args.push("--rm".to_string());
962        }
963
964        args.push("-i".to_string());
965
966        match request.sandbox.network {
967            NetworkPolicy::Disabled => {
968                args.push("--network=none".to_string());
969            }
970            NetworkPolicy::Enabled => {}
971        }
972
973        match &request.sandbox.filesystem {
974            FilesystemPolicy::None => {}
975            FilesystemPolicy::WorkspaceReadOnly { root } => {
976                args.push("-v".to_string());
977                args.push(format!("{}:/workspace:ro", root.display()));
978            }
979            FilesystemPolicy::WorkspaceReadWrite { root } => {
980                args.push("-v".to_string());
981                args.push(format!("{}:/workspace:rw", root.display()));
982            }
983            FilesystemPolicy::Paths { read_only, read_write } => {
984                for path in read_only {
985                    args.push("-v".to_string());
986                    args.push(format!("{}:{}:ro", path.display(), path.display()));
987                }
988                for path in read_write {
989                    args.push("-v".to_string());
990                    args.push(format!("{}:{}:rw", path.display(), path.display()));
991                }
992            }
993        }
994
995        if let EnvironmentPolicy::AllowList(vars) = &request.sandbox.environment {
996            for var in vars {
997                args.push("--env".to_string());
998                args.push(var.clone());
999            }
1000        }
1001
1002        if let Some(ref wd) = request.sandbox.working_directory {
1003            args.push("-w".to_string());
1004            args.push(wd.display().to_string());
1005        }
1006
1007        args.extend(self.config.extra_flags.clone());
1008        args.push(self.config.default_image.clone());
1009
1010        let code = match &request.payload {
1011            ExecutionPayload::Source { code } => code.clone(),
1012            ExecutionPayload::GuestModule { .. } => String::new(),
1013        };
1014
1015        match request.language {
1016            ExecutionLanguage::Python => {
1017                args.push("python3".to_string());
1018                args.push("-c".to_string());
1019                args.push(code);
1020            }
1021            ExecutionLanguage::JavaScript => {
1022                args.push("node".to_string());
1023                args.push("-e".to_string());
1024                args.push(code);
1025            }
1026            ExecutionLanguage::Command => {
1027                args.push("sh".to_string());
1028                args.push("-c".to_string());
1029                args.push(code);
1030            }
1031            _ => {}
1032        }
1033
1034        args.extend(request.argv.clone());
1035        args
1036    }
1037}
1038
1039impl Default for ContainerCommandExecutor {
1040    fn default() -> Self {
1041        Self::new(ContainerConfig::default())
1042    }
1043}
1044
1045#[async_trait]
1046impl CodeExecutor for ContainerCommandExecutor {
1047    fn name(&self) -> &str {
1048        "container-command"
1049    }
1050
1051    fn capabilities(&self) -> BackendCapabilities {
1052        BackendCapabilities {
1053            isolation: ExecutionIsolation::ContainerEphemeral,
1054            enforce_network_policy: true,
1055            enforce_filesystem_policy: true,
1056            enforce_environment_policy: true,
1057            enforce_timeout: true,
1058            supports_structured_output: true,
1059            supports_process_execution: true,
1060            supports_persistent_workspace: false,
1061            supports_interactive_sessions: false,
1062        }
1063    }
1064
1065    fn supports_language(&self, lang: &ExecutionLanguage) -> bool {
1066        matches!(
1067            lang,
1068            ExecutionLanguage::Python | ExecutionLanguage::JavaScript | ExecutionLanguage::Command
1069        )
1070    }
1071
1072    async fn execute(&self, request: ExecutionRequest) -> Result<ExecutionResult, ExecutionError> {
1073        let supported =
1074            [ExecutionLanguage::Python, ExecutionLanguage::JavaScript, ExecutionLanguage::Command];
1075        validate_request(&self.capabilities(), &supported, &request)?;
1076
1077        match &request.payload {
1078            ExecutionPayload::Source { code } if code.trim().is_empty() => {
1079                return Err(ExecutionError::InvalidRequest("empty source code".to_string()));
1080            }
1081            ExecutionPayload::Source { .. } => {}
1082            ExecutionPayload::GuestModule { .. } => {
1083                return Err(ExecutionError::InvalidRequest(
1084                    "ContainerCommandExecutor does not support guest modules".to_string(),
1085                ));
1086            }
1087        }
1088
1089        let start = Instant::now();
1090        let run_args = self.build_run_args(&request);
1091
1092        debug!(
1093            runtime = %self.config.runtime,
1094            image = %self.config.default_image,
1095            language = %request.language,
1096            "starting container execution"
1097        );
1098
1099        // Structured input takes precedence over raw stdin.
1100        let stdin = match (&request.input, &request.stdin) {
1101            (Some(input), _) => Some(serde_json::to_vec(input).unwrap_or_default()),
1102            (None, Some(raw)) => Some(raw.clone()),
1103            (None, None) => None,
1104        };
1105
1106        let mut cmd = tokio::process::Command::new(&self.config.runtime);
1107        cmd.args(&run_args)
1108            .stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() })
1109            .stdout(Stdio::piped())
1110            .stderr(Stdio::piped())
1111            .kill_on_drop(true);
1112
1113        let child = cmd.spawn().map_err(|e| {
1114            ExecutionError::ExecutionFailed(format!(
1115                "failed to spawn container runtime '{}': {e}",
1116                self.config.runtime
1117            ))
1118        })?;
1119
1120        let completed = host_process::run_to_completion(
1121            child,
1122            stdin,
1123            request.sandbox.timeout,
1124            request.sandbox.max_stdout_bytes,
1125            request.sandbox.max_stderr_bytes,
1126        )
1127        .await
1128        .map_err(|e| {
1129            ExecutionError::ExecutionFailed(format!("failed to wait for container: {e}"))
1130        })?;
1131
1132        let Some(output) = completed else {
1133            warn!("container execution timed out");
1134            return Ok(ExecutionResult {
1135                status: ExecutionStatus::Timeout,
1136                stdout: String::new(),
1137                stderr: String::new(),
1138                output: None,
1139                exit_code: None,
1140                stdout_truncated: false,
1141                stderr_truncated: false,
1142                duration_ms: start.elapsed().as_millis() as u64,
1143                metadata: None,
1144            });
1145        };
1146
1147        let duration_ms = start.elapsed().as_millis() as u64;
1148
1149        let raw_stdout = String::from_utf8_lossy(&output.stdout.bytes).into_owned();
1150        let raw_stderr = String::from_utf8_lossy(&output.stderr.bytes).into_owned();
1151
1152        let (stdout, stdout_truncated) =
1153            truncate_output(raw_stdout, request.sandbox.max_stdout_bytes);
1154        let (stderr, stderr_truncated) =
1155            truncate_output(raw_stderr, request.sandbox.max_stderr_bytes);
1156        let stdout_truncated = stdout_truncated || output.stdout.truncated;
1157        let stderr_truncated = stderr_truncated || output.stderr.truncated;
1158
1159        let (structured_output, display_stdout) = extract_structured_output(&stdout);
1160
1161        let status = if output.status.success() {
1162            ExecutionStatus::Success
1163        } else {
1164            ExecutionStatus::Failed
1165        };
1166
1167        info!(
1168            exit_code = output.status.code(),
1169            duration_ms,
1170            has_structured_output = structured_output.is_some(),
1171            "container execution completed"
1172        );
1173
1174        Ok(ExecutionResult {
1175            status,
1176            stdout: display_stdout,
1177            stderr,
1178            output: structured_output,
1179            exit_code: output.status.code(),
1180            stdout_truncated,
1181            stderr_truncated,
1182            duration_ms,
1183            metadata: None,
1184        })
1185    }
1186}
1187
1188// ── Shared helpers ─────────────────────────────────────────────────────
1189
1190/// Truncate output to the given byte limit.
1191fn truncate_output(output: String, max_bytes: usize) -> (String, bool) {
1192    if output.len() <= max_bytes {
1193        (output, false)
1194    } else {
1195        let truncated = output
1196            .char_indices()
1197            .take_while(|(i, _)| *i < max_bytes)
1198            .map(|(_, c)| c)
1199            .collect::<String>();
1200        (truncated, true)
1201    }
1202}
1203
1204/// Extract structured JSON output from the last line of stdout.
1205fn extract_structured_output(stdout: &str) -> (Option<serde_json::Value>, String) {
1206    let trimmed = stdout.trim_end();
1207    if trimmed.is_empty() {
1208        return (None, String::new());
1209    }
1210
1211    if let Some(last_newline_pos) = trimmed.rfind('\n') {
1212        let last_line = &trimmed[last_newline_pos + 1..];
1213        let before = &trimmed[..last_newline_pos];
1214
1215        if let Ok(value) = serde_json::from_str::<serde_json::Value>(last_line) {
1216            return (Some(value), before.to_string());
1217        }
1218    } else if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) {
1219        return (Some(value), String::new());
1220    }
1221
1222    (None, stdout.to_string())
1223}
1224
1225#[cfg(test)]
1226mod tests {
1227    use super::*;
1228
1229    #[test]
1230    fn capabilities_are_container_ephemeral() {
1231        let executor = ContainerCommandExecutor::default();
1232        let caps = executor.capabilities();
1233        assert_eq!(caps.isolation, ExecutionIsolation::ContainerEphemeral);
1234        assert!(caps.enforce_network_policy);
1235        assert!(caps.enforce_filesystem_policy);
1236        assert!(caps.enforce_environment_policy);
1237        assert!(caps.enforce_timeout);
1238        assert!(caps.supports_structured_output);
1239        assert!(caps.supports_process_execution);
1240        assert!(!caps.supports_persistent_workspace);
1241        assert!(!caps.supports_interactive_sessions);
1242    }
1243
1244    #[test]
1245    fn supports_python_js_command() {
1246        let executor = ContainerCommandExecutor::default();
1247        assert!(executor.supports_language(&ExecutionLanguage::Python));
1248        assert!(executor.supports_language(&ExecutionLanguage::JavaScript));
1249        assert!(executor.supports_language(&ExecutionLanguage::Command));
1250        assert!(!executor.supports_language(&ExecutionLanguage::Rust));
1251        assert!(!executor.supports_language(&ExecutionLanguage::Wasm));
1252    }
1253
1254    #[test]
1255    fn default_config() {
1256        let config = ContainerConfig::default();
1257        assert_eq!(config.runtime, "docker");
1258        assert_eq!(config.default_image, "python:3.12-slim");
1259        assert!(config.extra_flags.is_empty());
1260        assert!(config.auto_remove);
1261    }
1262
1263    #[test]
1264    fn build_run_args_basic_python() {
1265        let executor = ContainerCommandExecutor::default();
1266        let request = ExecutionRequest {
1267            language: ExecutionLanguage::Python,
1268            payload: ExecutionPayload::Source { code: "print('hello')".to_string() },
1269            argv: vec![],
1270            stdin: None,
1271            input: None,
1272            sandbox: crate::SandboxPolicy::strict_rust(),
1273            identity: None,
1274        };
1275
1276        let args = executor.build_run_args(&request);
1277        assert!(args.contains(&"run".to_string()));
1278        assert!(args.contains(&"--rm".to_string()));
1279        assert!(args.contains(&"-i".to_string()));
1280        assert!(args.contains(&"--network=none".to_string()));
1281        assert!(args.contains(&"python3".to_string()));
1282        assert!(args.contains(&"-c".to_string()));
1283        assert!(args.contains(&"print('hello')".to_string()));
1284    }
1285
1286    #[test]
1287    fn build_run_args_with_network_enabled() {
1288        let executor = ContainerCommandExecutor::default();
1289        let mut sandbox = crate::SandboxPolicy::strict_rust();
1290        sandbox.network = NetworkPolicy::Enabled;
1291
1292        let request = ExecutionRequest {
1293            language: ExecutionLanguage::Python,
1294            payload: ExecutionPayload::Source { code: "print('hello')".to_string() },
1295            argv: vec![],
1296            stdin: None,
1297            input: None,
1298            sandbox,
1299            identity: None,
1300        };
1301
1302        let args = executor.build_run_args(&request);
1303        assert!(!args.contains(&"--network=none".to_string()));
1304    }
1305
1306    #[test]
1307    fn docker_config_presets() {
1308        let py = DockerConfig::python();
1309        assert_eq!(py.image, "python:3.12-slim");
1310        assert!(py.network_disabled);
1311
1312        let node = DockerConfig::node();
1313        assert_eq!(node.image, "node:20-slim");
1314
1315        let custom = DockerConfig::custom("ubuntu:24.04");
1316        assert_eq!(custom.image, "ubuntu:24.04");
1317    }
1318
1319    fn policy(
1320        network: NetworkPolicy,
1321        filesystem: FilesystemPolicy,
1322        environment: EnvironmentPolicy,
1323    ) -> SandboxPolicy {
1324        SandboxPolicy { network, filesystem, environment, ..SandboxPolicy::strict_rust() }
1325    }
1326
1327    fn assert_rejected(config: &DockerConfig, policy: &SandboxPolicy, mentions: &str) {
1328        match config.validate_sandbox_policy(policy) {
1329            Err(ExecutionError::UnsupportedPolicy(message)) => {
1330                assert!(message.contains(mentions), "{message}");
1331            }
1332            other => panic!("expected UnsupportedPolicy mentioning `{mentions}`, got {other:?}"),
1333        }
1334    }
1335
1336    #[test]
1337    fn docker_policy_default_config_satisfies_strict_policy() {
1338        assert_eq!(
1339            DockerConfig::python().validate_sandbox_policy(&SandboxPolicy::strict_rust()),
1340            Ok(())
1341        );
1342    }
1343
1344    #[test]
1345    fn docker_policy_rejects_disabled_network_on_networked_container() {
1346        let config = DockerConfig::python().with_network();
1347        assert_rejected(&config, &SandboxPolicy::strict_rust(), "network");
1348
1349        let allows_network =
1350            policy(NetworkPolicy::Enabled, FilesystemPolicy::None, EnvironmentPolicy::None);
1351        assert_eq!(config.validate_sandbox_policy(&allows_network), Ok(()));
1352    }
1353
1354    #[test]
1355    fn docker_policy_allows_enabled_network_on_isolated_container() {
1356        let allows_network =
1357            policy(NetworkPolicy::Enabled, FilesystemPolicy::None, EnvironmentPolicy::None);
1358        assert_eq!(DockerConfig::python().validate_sandbox_policy(&allows_network), Ok(()));
1359    }
1360
1361    #[test]
1362    fn docker_policy_rejects_any_mount_without_filesystem_access() {
1363        let config = DockerConfig::python().bind_mount("/srv/data:/data:ro");
1364        assert_rejected(&config, &SandboxPolicy::strict_rust(), "/srv/data:/data:ro");
1365    }
1366
1367    #[test]
1368    fn docker_policy_read_only_workspace_requires_read_only_mounts_under_root() {
1369        let read_only = policy(
1370            NetworkPolicy::Disabled,
1371            FilesystemPolicy::WorkspaceReadOnly { root: PathBuf::from("/srv") },
1372            EnvironmentPolicy::None,
1373        );
1374
1375        let ro_under_root = DockerConfig::python().bind_mount("/srv/data:/data:ro");
1376        assert_eq!(ro_under_root.validate_sandbox_policy(&read_only), Ok(()));
1377
1378        let rw_under_root = DockerConfig::python().bind_mount("/srv/data:/data");
1379        assert_rejected(&rw_under_root, &read_only, "/srv/data:/data");
1380
1381        let ro_outside_root = DockerConfig::python().bind_mount("/home/user/.aws:/aws:ro");
1382        assert_rejected(&ro_outside_root, &read_only, "/home/user/.aws");
1383
1384        let escapes_root = DockerConfig::python().bind_mount("/srv/../etc:/etc-host:ro");
1385        assert_rejected(&escapes_root, &read_only, "/srv/../etc");
1386
1387        let sibling_prefix = DockerConfig::python().bind_mount("/srv-secrets:/s:ro");
1388        assert_rejected(&sibling_prefix, &read_only, "/srv-secrets");
1389
1390        let named_volume = DockerConfig::python().bind_mount("cache:/cache:ro");
1391        assert_rejected(&named_volume, &read_only, "cache:/cache:ro");
1392    }
1393
1394    #[test]
1395    fn docker_policy_read_write_workspace_accepts_any_mode_under_root() {
1396        let read_write = policy(
1397            NetworkPolicy::Disabled,
1398            FilesystemPolicy::WorkspaceReadWrite { root: PathBuf::from("/srv") },
1399            EnvironmentPolicy::None,
1400        );
1401        let config = DockerConfig::python()
1402            .bind_mount("/srv/data:/data")
1403            .bind_mount("/srv/models:/models:ro");
1404        assert_eq!(config.validate_sandbox_policy(&read_write), Ok(()));
1405    }
1406
1407    #[test]
1408    fn docker_policy_explicit_paths_match_mode() {
1409        let paths = policy(
1410            NetworkPolicy::Disabled,
1411            FilesystemPolicy::Paths {
1412                read_only: vec![PathBuf::from("/srv/models")],
1413                read_write: vec![PathBuf::from("/srv/out")],
1414            },
1415            EnvironmentPolicy::None,
1416        );
1417
1418        let config = DockerConfig::python()
1419            .bind_mount("/srv/models:/models:ro,z")
1420            .bind_mount("/srv/out:/out:rw");
1421        assert_eq!(config.validate_sandbox_policy(&paths), Ok(()));
1422
1423        let writable_read_only_path = DockerConfig::python().bind_mount("/srv/models:/models");
1424        assert_rejected(&writable_read_only_path, &paths, "/srv/models:/models");
1425    }
1426
1427    #[test]
1428    fn docker_policy_container_environment_must_be_allowlisted() {
1429        let config = DockerConfig::python().env("API_TOKEN=secret").env("MODE");
1430        assert_rejected(&config, &SandboxPolicy::strict_rust(), "API_TOKEN");
1431
1432        let only_mode = policy(
1433            NetworkPolicy::Disabled,
1434            FilesystemPolicy::None,
1435            EnvironmentPolicy::AllowList(vec!["MODE".to_string()]),
1436        );
1437        assert_rejected(&config, &only_mode, "API_TOKEN");
1438
1439        let both = policy(
1440            NetworkPolicy::Disabled,
1441            FilesystemPolicy::None,
1442            EnvironmentPolicy::AllowList(vec!["API_TOKEN".to_string(), "MODE".to_string()]),
1443        );
1444        assert_eq!(config.validate_sandbox_policy(&both), Ok(()));
1445    }
1446
1447    #[test]
1448    fn docker_config_builder_methods() {
1449        let config = DockerConfig::python()
1450            .pip_install(&["numpy", "pandas"])
1451            .with_network()
1452            .env("MY_VAR=hello")
1453            .bind_mount("/host/data:/data:ro");
1454
1455        assert!(!config.network_disabled);
1456        assert_eq!(config.setup_commands.len(), 1);
1457        assert!(config.setup_commands[0].contains("numpy"));
1458        assert_eq!(config.environment, vec!["MY_VAR=hello"]);
1459        assert_eq!(config.bind_mounts, vec!["/host/data:/data:ro"]);
1460    }
1461}