Skip to main content

minion_engine/sandbox/
config.rs

1// Sandbox config API — some items used only in integration paths
2#![allow(dead_code)]
3
4use std::collections::HashMap;
5
6use serde::{Deserialize, Serialize};
7
8/// Network policy for sandbox (allow/deny domain lists)
9#[derive(Debug, Clone, Default, Serialize, Deserialize)]
10pub struct NetworkConfig {
11    /// Domains/IPs to allow (empty = allow all)
12    pub allow: Vec<String>,
13    /// Domains/IPs to deny
14    pub deny: Vec<String>,
15}
16
17/// Resource limits for sandbox container
18#[derive(Debug, Clone, Default, Serialize, Deserialize)]
19pub struct ResourceConfig {
20    /// Number of CPUs (e.g., 2.0)
21    pub cpus: Option<f64>,
22    /// Memory limit (e.g., "2g", "512m")
23    pub memory: Option<String>,
24}
25
26/// Sandbox mode
27#[derive(Debug, Clone, Default, PartialEq, Eq)]
28pub enum SandboxMode {
29    /// No sandbox
30    #[default]
31    Disabled,
32    /// CLI --sandbox flag: wrap entire workflow execution
33    FullWorkflow,
34    /// config.agent.sandbox: true — only agent steps run in sandbox
35    AgentOnly,
36    /// config.global.sandbox.enabled: true — devbox with full config
37    Devbox,
38}
39
40/// Full sandbox configuration (parsed from workflow config or CLI flags)
41#[derive(Debug, Clone, Default, Serialize, Deserialize)]
42pub struct SandboxConfig {
43    pub enabled: bool,
44    /// Docker image to use (default: "minion-sandbox:latest")
45    pub image: Option<String>,
46    /// Host path to mount as workspace inside container
47    pub workspace: Option<String>,
48    pub network: NetworkConfig,
49    pub resources: ResourceConfig,
50    /// Host environment variables to forward into the container.
51    /// Each entry is a variable name (e.g. "GH_TOKEN"); the value is read
52    /// from the host environment at container-creation time.
53    pub env: Vec<String>,
54    /// Extra read-only volume mounts (host_path:container_path or host_path:container_path:mode).
55    /// Tilde (~) is expanded to $HOME on the host.
56    pub volumes: Vec<String>,
57    /// Glob patterns of files/dirs to exclude when copying workspace into the
58    /// container (e.g. "node_modules", "target").
59    pub exclude: Vec<String>,
60    /// DNS servers to use inside the container (e.g. "8.8.8.8").
61    /// Ensures name resolution works even with restricted networks.
62    pub dns: Vec<String>,
63}
64
65impl SandboxConfig {
66    /// Default image used when none is specified
67    pub const DEFAULT_IMAGE: &'static str = "minion-sandbox:latest";
68
69    /// Well-known env vars that are auto-forwarded when the user does NOT
70    /// specify an explicit `env:` list. This covers the most common
71    /// credentials needed by workflows.
72    pub const AUTO_ENV: &'static [&'static str] = &[
73        "ANTHROPIC_API_KEY",
74        "OPENAI_API_KEY",
75        "GH_TOKEN",
76        "GITHUB_TOKEN",
77    ];
78
79    /// Well-known directories to exclude when copying workspace into the
80    /// sandbox container. These are typically large build/cache directories
81    /// that would make the copy prohibitively slow and are not needed for
82    /// workflow execution.
83    pub const AUTO_EXCLUDE: &'static [&'static str] = &[
84        "target",
85        "node_modules",
86        "dist",
87        "build",
88        "__pycache__",
89        ".next",
90        ".nuxt",
91        "vendor",
92        ".tox",
93        ".venv",
94        "venv",
95    ];
96
97    /// Well-known host directories that are auto-mounted when the
98    /// user does NOT specify an explicit `volumes:` list.
99    /// Note: ~/.claude needs read-write access because Claude CLI writes session data.
100    /// Note: ~/.gitconfig is NOT mounted because the host gitconfig often
101    /// contains macOS-specific paths (e.g. credential helpers pointing to
102    /// /usr/local/bin/gh) and missing safe.directory entries. The sandbox
103    /// configures its own gitconfig after workspace copy.
104    pub const AUTO_VOLUMES: &'static [&'static str] = &[
105        "~/.config/gh:/root/.config/gh:ro",
106        "~/.claude:/root/.claude:rw",
107        "~/.ssh:/root/.ssh:ro",
108    ];
109
110    pub fn image(&self) -> &str {
111        self.image.as_deref().unwrap_or(Self::DEFAULT_IMAGE)
112    }
113
114    /// Return the effective env-var list: explicit config overrides auto-env.
115    pub fn effective_env(&self) -> Vec<String> {
116        if self.env.is_empty() {
117            Self::AUTO_ENV.iter().map(|s| (*s).to_string()).collect()
118        } else {
119            self.env.clone()
120        }
121    }
122
123    /// Return the effective volume list: explicit config overrides auto-volumes.
124    /// Tilde (~) is expanded to $HOME on the host.
125    pub fn effective_volumes(&self) -> Vec<String> {
126        let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
127        let raw = if self.volumes.is_empty() {
128            Self::AUTO_VOLUMES.iter().map(|s| (*s).to_string()).collect::<Vec<_>>()
129        } else {
130            self.volumes.clone()
131        };
132        raw.into_iter()
133            .map(|v| v.replace('~', &home))
134            .filter(|v| {
135                // Only mount if the host path actually exists
136                let host_path = v.split(':').next().unwrap_or("");
137                std::path::Path::new(host_path).exists()
138            })
139            .collect()
140    }
141
142    /// Return the effective exclude list: explicit config overrides auto-exclude.
143    pub fn effective_exclude(&self) -> Vec<String> {
144        if self.exclude.is_empty() {
145            Self::AUTO_EXCLUDE.iter().map(|s| (*s).to_string()).collect()
146        } else {
147            self.exclude.clone()
148        }
149    }
150
151    /// Helper: parse a YAML string-list from a mapping key.
152    fn parse_string_list(
153        mapping: &serde_yaml::Mapping,
154        key: &str,
155    ) -> Vec<String> {
156        mapping
157            .get(serde_yaml::Value::String(key.into()))
158            .and_then(|v| v.as_sequence())
159            .map(|seq| {
160                seq.iter()
161                    .filter_map(|v| v.as_str().map(String::from))
162                    .collect()
163            })
164            .unwrap_or_default()
165    }
166
167    /// Parse SandboxConfig from a global config map (Devbox mode)
168    pub fn from_global_config(config: &HashMap<String, serde_yaml::Value>) -> Self {
169        let sandbox = match config.get("sandbox") {
170            Some(serde_yaml::Value::Mapping(m)) => m,
171            _ => return Self::default(),
172        };
173
174        let enabled = sandbox
175            .get(serde_yaml::Value::String("enabled".into()))
176            .and_then(|v| v.as_bool())
177            .unwrap_or(false);
178
179        let image = sandbox
180            .get(serde_yaml::Value::String("image".into()))
181            .and_then(|v| v.as_str())
182            .map(String::from);
183
184        let workspace = sandbox
185            .get(serde_yaml::Value::String("workspace".into()))
186            .and_then(|v| v.as_str())
187            .map(String::from);
188
189        let (allow, deny) = match sandbox.get(serde_yaml::Value::String("network".into())) {
190            Some(serde_yaml::Value::Mapping(net)) => {
191                (Self::parse_string_list(net, "allow"), Self::parse_string_list(net, "deny"))
192            }
193            _ => (vec![], vec![]),
194        };
195
196        let (cpus, memory) = match sandbox.get(serde_yaml::Value::String("resources".into())) {
197            Some(serde_yaml::Value::Mapping(res)) => {
198                let cpus = res
199                    .get(serde_yaml::Value::String("cpus".into()))
200                    .and_then(|v| v.as_f64());
201                let memory = res
202                    .get(serde_yaml::Value::String("memory".into()))
203                    .and_then(|v| v.as_str())
204                    .map(String::from);
205                (cpus, memory)
206            }
207            _ => (None, None),
208        };
209
210        let env = Self::parse_string_list(sandbox, "env");
211        let volumes = Self::parse_string_list(sandbox, "volumes");
212        let exclude = Self::parse_string_list(sandbox, "exclude");
213        let dns = Self::parse_string_list(sandbox, "dns");
214
215        Self {
216            enabled,
217            image,
218            workspace,
219            network: NetworkConfig { allow, deny },
220            resources: ResourceConfig { cpus, memory },
221            env,
222            volumes,
223            exclude,
224            dns,
225        }
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn default_image() {
235        let cfg = SandboxConfig::default();
236        assert_eq!(cfg.image(), "minion-sandbox:latest");
237    }
238
239    #[test]
240    fn custom_image_override() {
241        let cfg = SandboxConfig {
242            image: Some("node:20".to_string()),
243            ..Default::default()
244        };
245        assert_eq!(cfg.image(), "node:20");
246    }
247
248    #[test]
249    fn from_global_config_parses_all_fields() {
250        let yaml = r#"
251sandbox:
252  enabled: true
253  image: "rust:1.80"
254  workspace: "/app"
255  network:
256    allow:
257      - "api.anthropic.com"
258    deny:
259      - "0.0.0.0/0"
260  resources:
261    cpus: 2.0
262    memory: "4g"
263  env:
264    - ANTHROPIC_API_KEY
265    - GH_TOKEN
266    - CUSTOM_SECRET
267  volumes:
268    - "~/.config/gh:/root/.config/gh:ro"
269    - "~/.claude:/root/.claude:ro"
270  exclude:
271    - node_modules
272    - target
273    - .git/objects
274  dns:
275    - "8.8.8.8"
276    - "1.1.1.1"
277"#;
278        let map: HashMap<String, serde_yaml::Value> = serde_yaml::from_str(yaml).unwrap();
279        let cfg = SandboxConfig::from_global_config(&map);
280
281        assert!(cfg.enabled);
282        assert_eq!(cfg.image(), "rust:1.80");
283        assert_eq!(cfg.workspace.as_deref(), Some("/app"));
284        assert_eq!(cfg.network.allow, ["api.anthropic.com"]);
285        assert_eq!(cfg.network.deny, ["0.0.0.0/0"]);
286        assert_eq!(cfg.resources.cpus, Some(2.0));
287        assert_eq!(cfg.resources.memory.as_deref(), Some("4g"));
288        assert_eq!(cfg.env, ["ANTHROPIC_API_KEY", "GH_TOKEN", "CUSTOM_SECRET"]);
289        assert_eq!(cfg.volumes.len(), 2);
290        assert_eq!(cfg.exclude, ["node_modules", "target", ".git/objects"]);
291        assert_eq!(cfg.dns, ["8.8.8.8", "1.1.1.1"]);
292    }
293
294    #[test]
295    fn from_global_config_empty_returns_default() {
296        let map: HashMap<String, serde_yaml::Value> = HashMap::new();
297        let cfg = SandboxConfig::from_global_config(&map);
298        assert!(!cfg.enabled);
299        assert!(cfg.image.is_none());
300    }
301
302    #[test]
303    fn effective_env_uses_auto_when_empty() {
304        let cfg = SandboxConfig::default();
305        let env = cfg.effective_env();
306        assert!(env.contains(&"ANTHROPIC_API_KEY".to_string()));
307        assert!(env.contains(&"GH_TOKEN".to_string()));
308    }
309
310    #[test]
311    fn effective_env_uses_explicit_when_set() {
312        let cfg = SandboxConfig {
313            env: vec!["MY_CUSTOM_KEY".to_string()],
314            ..Default::default()
315        };
316        let env = cfg.effective_env();
317        assert_eq!(env, vec!["MY_CUSTOM_KEY"]);
318        assert!(!env.contains(&"ANTHROPIC_API_KEY".to_string()));
319    }
320
321    #[test]
322    fn effective_volumes_filters_nonexistent_paths() {
323        let cfg = SandboxConfig {
324            volumes: vec![
325                "/nonexistent/path/abc123:/container/path:ro".to_string(),
326            ],
327            ..Default::default()
328        };
329        let vols = cfg.effective_volumes();
330        assert!(vols.is_empty(), "should filter out non-existent host paths");
331    }
332}