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        // Root mounts (for cmd steps)
106        "~/.config/gh:/root/.config/gh:ro",
107        "~/.claude:/root/.claude:rw",
108        "~/.ssh:/root/.ssh:ro",
109        // Minion user mounts (for agent steps — Claude CLI runs as minion)
110        "~/.config/gh:/home/minion/.config/gh:ro",
111        "~/.claude:/home/minion/.claude:rw",
112        "~/.claude.json:/home/minion/.claude.json:ro",
113    ];
114
115    pub fn image(&self) -> &str {
116        self.image.as_deref().unwrap_or(Self::DEFAULT_IMAGE)
117    }
118
119    /// Secrets that are proxied and should NOT be passed as env vars into the container.
120    pub const PROXIED_SECRETS: &'static [&'static str] = &["ANTHROPIC_API_KEY"];
121
122    /// Return the effective env-var list: explicit config overrides auto-env.
123    pub fn effective_env(&self) -> Vec<String> {
124        if self.env.is_empty() {
125            Self::AUTO_ENV.iter().map(|s| (*s).to_string()).collect()
126        } else {
127            self.env.clone()
128        }
129    }
130
131    /// Return env vars to forward when the API proxy is active.
132    /// Excludes secrets that are handled by the proxy (e.g. ANTHROPIC_API_KEY).
133    pub fn effective_env_with_proxy(&self) -> Vec<String> {
134        self.effective_env()
135            .into_iter()
136            .filter(|k| !Self::PROXIED_SECRETS.contains(&k.as_str()))
137            .collect()
138    }
139
140    /// Return the effective volume list: explicit config overrides auto-volumes.
141    /// Tilde (~) is expanded to $HOME on the host.
142    pub fn effective_volumes(&self) -> Vec<String> {
143        let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
144        let raw = if self.volumes.is_empty() {
145            Self::AUTO_VOLUMES.iter().map(|s| (*s).to_string()).collect::<Vec<_>>()
146        } else {
147            self.volumes.clone()
148        };
149        raw.into_iter()
150            .map(|v| v.replace('~', &home))
151            .filter(|v| {
152                // Only mount if the host path actually exists
153                let host_path = v.split(':').next().unwrap_or("");
154                std::path::Path::new(host_path).exists()
155            })
156            .collect()
157    }
158
159    /// Return the effective exclude list: explicit config overrides auto-exclude.
160    pub fn effective_exclude(&self) -> Vec<String> {
161        if self.exclude.is_empty() {
162            Self::AUTO_EXCLUDE.iter().map(|s| (*s).to_string()).collect()
163        } else {
164            self.exclude.clone()
165        }
166    }
167
168    /// Helper: parse a YAML string-list from a mapping key.
169    fn parse_string_list(
170        mapping: &serde_yaml::Mapping,
171        key: &str,
172    ) -> Vec<String> {
173        mapping
174            .get(serde_yaml::Value::String(key.into()))
175            .and_then(|v| v.as_sequence())
176            .map(|seq| {
177                seq.iter()
178                    .filter_map(|v| v.as_str().map(String::from))
179                    .collect()
180            })
181            .unwrap_or_default()
182    }
183
184    /// Parse SandboxConfig from a global config map (Devbox mode)
185    pub fn from_global_config(config: &HashMap<String, serde_yaml::Value>) -> Self {
186        let sandbox = match config.get("sandbox") {
187            Some(serde_yaml::Value::Mapping(m)) => m,
188            _ => return Self::default(),
189        };
190
191        let enabled = sandbox
192            .get(serde_yaml::Value::String("enabled".into()))
193            .and_then(|v| v.as_bool())
194            .unwrap_or(false);
195
196        let image = sandbox
197            .get(serde_yaml::Value::String("image".into()))
198            .and_then(|v| v.as_str())
199            .map(String::from);
200
201        let workspace = sandbox
202            .get(serde_yaml::Value::String("workspace".into()))
203            .and_then(|v| v.as_str())
204            .map(String::from);
205
206        let (allow, deny) = match sandbox.get(serde_yaml::Value::String("network".into())) {
207            Some(serde_yaml::Value::Mapping(net)) => {
208                (Self::parse_string_list(net, "allow"), Self::parse_string_list(net, "deny"))
209            }
210            _ => (vec![], vec![]),
211        };
212
213        let (cpus, memory) = match sandbox.get(serde_yaml::Value::String("resources".into())) {
214            Some(serde_yaml::Value::Mapping(res)) => {
215                let cpus = res
216                    .get(serde_yaml::Value::String("cpus".into()))
217                    .and_then(|v| v.as_f64());
218                let memory = res
219                    .get(serde_yaml::Value::String("memory".into()))
220                    .and_then(|v| v.as_str())
221                    .map(String::from);
222                (cpus, memory)
223            }
224            _ => (None, None),
225        };
226
227        let env = Self::parse_string_list(sandbox, "env");
228        let volumes = Self::parse_string_list(sandbox, "volumes");
229        let exclude = Self::parse_string_list(sandbox, "exclude");
230        let dns = Self::parse_string_list(sandbox, "dns");
231
232        Self {
233            enabled,
234            image,
235            workspace,
236            network: NetworkConfig { allow, deny },
237            resources: ResourceConfig { cpus, memory },
238            env,
239            volumes,
240            exclude,
241            dns,
242        }
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn default_image() {
252        let cfg = SandboxConfig::default();
253        assert_eq!(cfg.image(), "minion-sandbox:latest");
254    }
255
256    #[test]
257    fn custom_image_override() {
258        let cfg = SandboxConfig {
259            image: Some("node:20".to_string()),
260            ..Default::default()
261        };
262        assert_eq!(cfg.image(), "node:20");
263    }
264
265    #[test]
266    fn from_global_config_parses_all_fields() {
267        let yaml = r#"
268sandbox:
269  enabled: true
270  image: "rust:1.80"
271  workspace: "/app"
272  network:
273    allow:
274      - "api.anthropic.com"
275    deny:
276      - "0.0.0.0/0"
277  resources:
278    cpus: 2.0
279    memory: "4g"
280  env:
281    - ANTHROPIC_API_KEY
282    - GH_TOKEN
283    - CUSTOM_SECRET
284  volumes:
285    - "~/.config/gh:/root/.config/gh:ro"
286    - "~/.claude:/root/.claude:ro"
287  exclude:
288    - node_modules
289    - target
290    - .git/objects
291  dns:
292    - "8.8.8.8"
293    - "1.1.1.1"
294"#;
295        let map: HashMap<String, serde_yaml::Value> = serde_yaml::from_str(yaml).unwrap();
296        let cfg = SandboxConfig::from_global_config(&map);
297
298        assert!(cfg.enabled);
299        assert_eq!(cfg.image(), "rust:1.80");
300        assert_eq!(cfg.workspace.as_deref(), Some("/app"));
301        assert_eq!(cfg.network.allow, ["api.anthropic.com"]);
302        assert_eq!(cfg.network.deny, ["0.0.0.0/0"]);
303        assert_eq!(cfg.resources.cpus, Some(2.0));
304        assert_eq!(cfg.resources.memory.as_deref(), Some("4g"));
305        assert_eq!(cfg.env, ["ANTHROPIC_API_KEY", "GH_TOKEN", "CUSTOM_SECRET"]);
306        assert_eq!(cfg.volumes.len(), 2);
307        assert_eq!(cfg.exclude, ["node_modules", "target", ".git/objects"]);
308        assert_eq!(cfg.dns, ["8.8.8.8", "1.1.1.1"]);
309    }
310
311    #[test]
312    fn from_global_config_empty_returns_default() {
313        let map: HashMap<String, serde_yaml::Value> = HashMap::new();
314        let cfg = SandboxConfig::from_global_config(&map);
315        assert!(!cfg.enabled);
316        assert!(cfg.image.is_none());
317    }
318
319    #[test]
320    fn effective_env_uses_auto_when_empty() {
321        let cfg = SandboxConfig::default();
322        let env = cfg.effective_env();
323        assert!(env.contains(&"ANTHROPIC_API_KEY".to_string()));
324        assert!(env.contains(&"GH_TOKEN".to_string()));
325    }
326
327    #[test]
328    fn effective_env_uses_explicit_when_set() {
329        let cfg = SandboxConfig {
330            env: vec!["MY_CUSTOM_KEY".to_string()],
331            ..Default::default()
332        };
333        let env = cfg.effective_env();
334        assert_eq!(env, vec!["MY_CUSTOM_KEY"]);
335        assert!(!env.contains(&"ANTHROPIC_API_KEY".to_string()));
336    }
337
338    #[test]
339    fn effective_volumes_filters_nonexistent_paths() {
340        let cfg = SandboxConfig {
341            volumes: vec![
342                "/nonexistent/path/abc123:/container/path:ro".to_string(),
343            ],
344            ..Default::default()
345        };
346        let vols = cfg.effective_volumes();
347        assert!(vols.is_empty(), "should filter out non-existent host paths");
348    }
349}