Skip to main content

embacle/
container.rs

1// ABOUTME: Container-based execution backend for CLI runners
2// ABOUTME: Spawns CLI commands in ephemeral containers with security isolation
3//
4// SPDX-License-Identifier: Apache-2.0
5// Copyright (c) 2026 dravr.ai
6
7use std::env;
8use std::fmt;
9use std::path::{Path, PathBuf};
10use std::time::Duration;
11
12use crate::types::RunnerError;
13use tokio::fs;
14use tokio::process::Command;
15use tracing::{debug, warn};
16
17use crate::process::{run_cli_command, CliOutput};
18
19/// Environment variable for the container image
20const ENV_CONTAINER_IMAGE: &str = "CLI_LLM_CONTAINER_IMAGE";
21/// Environment variable for the memory limit
22const ENV_CONTAINER_MEMORY: &str = "CLI_LLM_CONTAINER_MEMORY";
23/// Environment variable for the PIDs limit
24const ENV_CONTAINER_PIDS_LIMIT: &str = "CLI_LLM_CONTAINER_PIDS_LIMIT";
25/// Environment variable for the network mode
26const ENV_CONTAINER_NETWORK: &str = "CLI_LLM_CONTAINER_NETWORK";
27
28/// Network isolation mode for the container
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum NetworkMode {
31    /// No network access (`--network none`)
32    None,
33    /// Share the host network namespace (`--network host`)
34    Host,
35    /// Use a named Docker network (`--network <name>`)
36    Custom(String),
37}
38
39impl fmt::Display for NetworkMode {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        match self {
42            Self::None => write!(f, "none"),
43            Self::Host => write!(f, "host"),
44            Self::Custom(name) => write!(f, "{name}"),
45        }
46    }
47}
48
49/// Bind mount specification for a container volume
50#[derive(Debug, Clone)]
51pub struct Mount {
52    /// Host path to mount from
53    pub source: PathBuf,
54    /// Container path to mount to
55    pub target: PathBuf,
56    /// Whether the mount is read-only inside the container
57    pub read_only: bool,
58}
59
60/// Configuration for container-based CLI execution
61#[derive(Debug, Clone)]
62pub struct ContainerConfig {
63    /// Container image reference (e.g. `ghcr.io/org/cli-llm-runner:latest`)
64    pub image: String,
65    /// Memory limit for the container (e.g. `"512m"`)
66    pub memory_limit: Option<String>,
67    /// Maximum number of PIDs allowed inside the container
68    pub pids_limit: Option<u32>,
69    /// Network isolation mode
70    pub network_mode: NetworkMode,
71    /// Additional bind mounts passed to the container
72    pub extra_mounts: Vec<Mount>,
73    /// Environment variables injected into the container
74    pub env_vars: Vec<(String, String)>,
75}
76
77impl ContainerConfig {
78    /// Build a container configuration from environment variables
79    ///
80    /// Reads:
81    /// - `CLI_LLM_CONTAINER_IMAGE` → image (required)
82    /// - `CLI_LLM_CONTAINER_MEMORY` → memory limit
83    /// - `CLI_LLM_CONTAINER_PIDS_LIMIT` → PIDs limit
84    /// - `CLI_LLM_CONTAINER_NETWORK` → network mode (`none`, `host`, or custom name)
85    ///
86    /// # Errors
87    ///
88    /// Returns `RunnerError` if `CLI_LLM_CONTAINER_IMAGE` is not set or if
89    /// `CLI_LLM_CONTAINER_PIDS_LIMIT` is set but not a valid `u32`.
90    pub fn from_env() -> Result<Self, RunnerError> {
91        let image = env::var(ENV_CONTAINER_IMAGE).map_err(|_| {
92            RunnerError::internal(format!(
93                "{ENV_CONTAINER_IMAGE} environment variable is required"
94            ))
95        })?;
96
97        let memory_limit = env::var(ENV_CONTAINER_MEMORY).ok();
98
99        let pids_limit = match env::var(ENV_CONTAINER_PIDS_LIMIT) {
100            Ok(val) => {
101                let parsed = val.trim().parse::<u32>().map_err(|e| {
102                    RunnerError::internal(format!(
103                        "{ENV_CONTAINER_PIDS_LIMIT} is not a valid u32: {e}"
104                    ))
105                })?;
106                Some(parsed)
107            }
108            Err(_) => Option::None,
109        };
110
111        let network_mode =
112            env::var(ENV_CONTAINER_NETWORK).map_or(NetworkMode::None, |val| {
113                match val.trim().to_lowercase().as_str() {
114                    "none" => NetworkMode::None,
115                    "host" => NetworkMode::Host,
116                    other => NetworkMode::Custom(other.to_owned()),
117                }
118            });
119
120        Ok(Self {
121            image,
122            memory_limit,
123            pids_limit,
124            network_mode,
125            extra_mounts: Vec::new(),
126            env_vars: Vec::new(),
127        })
128    }
129}
130
131/// Executor that runs CLI commands inside ephemeral Docker containers
132///
133/// Each invocation creates a fresh `docker run --rm` container with
134/// a read-only root filesystem, all capabilities dropped, and
135/// `no-new-privileges` enforced. A writable scratch directory is
136/// bind-mounted at `/scratch` for temporary files.
137#[derive(Debug, Clone)]
138pub struct ContainerExecutor {
139    /// Container configuration controlling image, limits, and mounts
140    config: ContainerConfig,
141}
142
143impl ContainerExecutor {
144    /// Create a new container executor with the given configuration
145    #[must_use]
146    pub const fn new(config: ContainerConfig) -> Self {
147        Self { config }
148    }
149
150    /// Execute a CLI command inside an ephemeral container
151    ///
152    /// The container is created with security hardening flags:
153    /// - `--rm` to auto-remove after exit
154    /// - `--read-only` root filesystem
155    /// - `--cap-drop=ALL` to drop all Linux capabilities
156    /// - `--security-opt=no-new-privileges` to prevent privilege escalation
157    /// - A writable `/scratch` tmpfs for temporary files
158    ///
159    /// If `input` is provided, it is written to a temp file inside the
160    /// scratch mount and piped as stdin to the command.
161    ///
162    /// # Errors
163    ///
164    /// Returns `RunnerError` if:
165    /// - The scratch directory cannot be created or written to
166    /// - The docker command fails to spawn or times out
167    /// - The container exits with a non-zero code
168    pub async fn execute(
169        &self,
170        binary_name: &str,
171        args: &[&str],
172        input: Option<&str>,
173        timeout: Duration,
174        max_output_bytes: usize,
175    ) -> Result<CliOutput, RunnerError> {
176        let scratch_dir = tempfile::tempdir().map_err(|e| {
177            RunnerError::internal(format!("Failed to create scratch directory: {e}"))
178        })?;
179
180        let scratch_path = scratch_dir.path();
181
182        // Write stdin content to a file in the scratch directory
183        let stdin_file_container_path = if let Some(content) = input {
184            let stdin_path = scratch_path.join("stdin.txt");
185            fs::write(&stdin_path, content)
186                .await
187                .map_err(|e| RunnerError::internal(format!("Failed to write stdin file: {e}")))?;
188            Some("/scratch/stdin.txt".to_owned())
189        } else {
190            Option::None
191        };
192
193        let docker_args = build_docker_args(
194            &self.config,
195            scratch_path,
196            binary_name,
197            args,
198            stdin_file_container_path.as_deref(),
199        );
200
201        debug!(
202            image = %self.config.image,
203            binary = binary_name,
204            scratch = %scratch_path.display(),
205            "Launching container"
206        );
207
208        let mut cmd = Command::new("docker");
209        cmd.args(&docker_args);
210
211        let result = run_cli_command(&mut cmd, timeout, max_output_bytes).await;
212
213        // Cleanup is handled by TempDir drop, but log if removal fails
214        if let Err(e) = scratch_dir.close() {
215            warn!("Failed to clean up scratch directory: {e}");
216        }
217
218        result
219    }
220}
221
222/// Build the full `docker run` argument list
223fn build_docker_args(
224    config: &ContainerConfig,
225    scratch_path: &Path,
226    binary_name: &str,
227    args: &[&str],
228    stdin_file_container_path: Option<&str>,
229) -> Vec<String> {
230    let mut docker_args: Vec<String> = vec![
231        "run".to_owned(),
232        "--rm".to_owned(),
233        "--read-only".to_owned(),
234        "--cap-drop=ALL".to_owned(),
235        "--security-opt=no-new-privileges".to_owned(),
236    ];
237
238    // Memory limit
239    if let Some(ref mem) = config.memory_limit {
240        docker_args.push(format!("--memory={mem}"));
241    }
242
243    // PIDs limit
244    if let Some(pids) = config.pids_limit {
245        docker_args.push(format!("--pids-limit={pids}"));
246    }
247
248    // Network mode
249    docker_args.push(format!("--network={}", config.network_mode));
250
251    // Scratch mount (writable)
252    docker_args.push("-v".to_owned());
253    docker_args.push(format!("{}:/scratch", scratch_path.display()));
254
255    // Extra mounts from configuration
256    for mount in &config.extra_mounts {
257        docker_args.push("-v".to_owned());
258        let ro_suffix = if mount.read_only { ":ro" } else { "" };
259        docker_args.push(format!(
260            "{}:{}{}",
261            mount.source.display(),
262            mount.target.display(),
263            ro_suffix
264        ));
265    }
266
267    // Environment variables
268    for (key, value) in &config.env_vars {
269        docker_args.push("-e".to_owned());
270        docker_args.push(format!("{key}={value}"));
271    }
272
273    // If stdin content was provided, use shell to redirect it
274    if let Some(stdin_path) = stdin_file_container_path {
275        docker_args.push("-i".to_owned());
276        docker_args.push(config.image.clone());
277        docker_args.push("sh".to_owned());
278        docker_args.push("-c".to_owned());
279
280        let escaped_args: Vec<String> = args.iter().map(|a| shell_escape(a)).collect();
281        let cmd_str = format!("{binary_name} {} < {stdin_path}", escaped_args.join(" "));
282        docker_args.push(cmd_str);
283    } else {
284        docker_args.push(config.image.clone());
285        docker_args.push(binary_name.to_owned());
286        docker_args.extend(args.iter().map(|a| (*a).to_owned()));
287    }
288
289    docker_args
290}
291
292/// Escape a shell argument by wrapping in single quotes and escaping
293/// embedded single quotes.
294fn shell_escape(arg: &str) -> String {
295    if arg.is_empty() {
296        return "''".to_owned();
297    }
298    // Replace single quotes with '\'' (end quote, escaped quote, start quote)
299    format!("'{}'", arg.replace('\'', "'\\''"))
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    fn base_config() -> ContainerConfig {
307        ContainerConfig {
308            image: "ghcr.io/test/runner:latest".to_owned(),
309            memory_limit: None,
310            pids_limit: None,
311            network_mode: NetworkMode::None,
312            extra_mounts: Vec::new(),
313            env_vars: Vec::new(),
314        }
315    }
316
317    #[test]
318    fn test_docker_args_security_hardening() {
319        let config = base_config();
320        let args = build_docker_args(
321            &config,
322            Path::new("/tmp/scratch"),
323            "claude",
324            &["-p", "hi"],
325            None,
326        );
327
328        assert!(args.contains(&"--rm".to_owned()));
329        assert!(args.contains(&"--read-only".to_owned()));
330        assert!(args.contains(&"--cap-drop=ALL".to_owned()));
331        assert!(args.contains(&"--security-opt=no-new-privileges".to_owned()));
332    }
333
334    #[test]
335    fn test_docker_args_memory_and_pids_limits() {
336        let mut config = base_config();
337        config.memory_limit = Some("512m".to_owned());
338        config.pids_limit = Some(100);
339
340        let args = build_docker_args(&config, Path::new("/tmp/scratch"), "claude", &[], None);
341
342        assert!(args.contains(&"--memory=512m".to_owned()));
343        assert!(args.contains(&"--pids-limit=100".to_owned()));
344    }
345
346    #[test]
347    fn test_docker_args_network_modes() {
348        let mut config = base_config();
349
350        config.network_mode = NetworkMode::None;
351        let args = build_docker_args(&config, Path::new("/tmp/s"), "claude", &[], None);
352        assert!(args.contains(&"--network=none".to_owned()));
353
354        config.network_mode = NetworkMode::Host;
355        let args = build_docker_args(&config, Path::new("/tmp/s"), "claude", &[], None);
356        assert!(args.contains(&"--network=host".to_owned()));
357
358        config.network_mode = NetworkMode::Custom("my-net".to_owned());
359        let args = build_docker_args(&config, Path::new("/tmp/s"), "claude", &[], None);
360        assert!(args.contains(&"--network=my-net".to_owned()));
361    }
362
363    #[test]
364    fn test_docker_args_extra_mounts() {
365        let mut config = base_config();
366        config.extra_mounts = vec![
367            Mount {
368                source: PathBuf::from("/host/data"),
369                target: PathBuf::from("/container/data"),
370                read_only: true,
371            },
372            Mount {
373                source: PathBuf::from("/host/work"),
374                target: PathBuf::from("/container/work"),
375                read_only: false,
376            },
377        ];
378
379        let args = build_docker_args(&config, Path::new("/tmp/scratch"), "claude", &[], None);
380
381        assert!(args.contains(&"/host/data:/container/data:ro".to_owned()));
382        assert!(args.contains(&"/host/work:/container/work".to_owned()));
383    }
384
385    #[test]
386    fn test_docker_args_env_vars() {
387        let mut config = base_config();
388        config.env_vars = vec![("API_KEY".to_owned(), "secret123".to_owned())];
389
390        let args = build_docker_args(&config, Path::new("/tmp/scratch"), "claude", &[], None);
391
392        assert!(args.contains(&"-e".to_owned()));
393        assert!(args.contains(&"API_KEY=secret123".to_owned()));
394    }
395
396    #[test]
397    fn test_docker_args_with_stdin_redirect() {
398        let config = base_config();
399        let args = build_docker_args(
400            &config,
401            Path::new("/tmp/scratch"),
402            "claude",
403            &["-p", "hello"],
404            Some("/scratch/stdin.txt"),
405        );
406
407        assert!(args.contains(&"-i".to_owned()));
408        assert!(args.contains(&"sh".to_owned()));
409        assert!(args.contains(&"-c".to_owned()));
410        // The last arg should be the shell command with stdin redirect
411        let last = args.last().unwrap(); // Safe: test assertion
412        assert!(last.contains("< /scratch/stdin.txt"));
413        assert!(last.contains("claude"));
414    }
415
416    #[test]
417    fn test_docker_args_without_stdin() {
418        let config = base_config();
419        let args = build_docker_args(
420            &config,
421            Path::new("/tmp/scratch"),
422            "claude",
423            &["-p", "hello"],
424            None,
425        );
426
427        // Should end with: image, binary, args
428        assert!(args.contains(&"ghcr.io/test/runner:latest".to_owned()));
429        assert!(args.contains(&"claude".to_owned()));
430        assert!(args.contains(&"-p".to_owned()));
431        assert!(args.contains(&"hello".to_owned()));
432        // Should NOT contain shell redirect markers
433        assert!(!args.contains(&"sh".to_owned()));
434    }
435
436    #[test]
437    fn test_shell_escape_empty() {
438        assert_eq!(shell_escape(""), "''");
439    }
440
441    #[test]
442    fn test_shell_escape_simple() {
443        assert_eq!(shell_escape("hello"), "'hello'");
444    }
445
446    #[test]
447    fn test_shell_escape_single_quotes() {
448        assert_eq!(shell_escape("it's"), "'it'\\''s'");
449    }
450
451    #[test]
452    fn test_network_mode_display() {
453        assert_eq!(format!("{}", NetworkMode::None), "none");
454        assert_eq!(format!("{}", NetworkMode::Host), "host");
455        assert_eq!(
456            format!("{}", NetworkMode::Custom("my-net".to_owned())),
457            "my-net"
458        );
459    }
460}