Skip to main content

vtcode_safety/sandboxing/
child_spawn.rs

1//! Child process spawning with sandbox-aware environment handling.
2//!
3//! Implements patterns from the Codex sandbox model:
4//! - Environment variable sanitization (remove sensitive vars)
5//! - Parent death signal (PR_SET_PDEATHSIG on Linux)
6//! - Sandbox identification markers for downstream tools
7
8use hashbrown::HashMap;
9use std::path::Path;
10
11/// Environment variables that should be filtered from sandboxed processes.
12///
13/// Following the field guide: "Completely clear the environment and rebuild it
14/// with only the variables you actually want."
15pub const FILTERED_ENV_VARS: &[&str] = &[
16    // API keys and tokens
17    "OPENAI_API_KEY",
18    "ANTHROPIC_API_KEY",
19    "GEMINI_API_KEY",
20    "XAI_API_KEY",
21    "DEEPSEEK_API_KEY",
22    "OPENROUTER_API_KEY",
23    "GROQ_API_KEY",
24    "MISTRAL_API_KEY",
25    "COHERE_API_KEY",
26    "AZURE_OPENAI_API_KEY",
27    "HUGGINGFACE_API_KEY",
28    "HF_TOKEN",
29    // Cloud provider credentials
30    "AWS_ACCESS_KEY_ID",
31    "AWS_SECRET_ACCESS_KEY",
32    "AWS_SESSION_TOKEN",
33    "GOOGLE_APPLICATION_CREDENTIALS",
34    "GOOGLE_CLOUD_PROJECT",
35    "AZURE_CLIENT_ID",
36    "AZURE_CLIENT_SECRET",
37    "AZURE_TENANT_ID",
38    "AZURE_SUBSCRIPTION_ID",
39    // GitHub tokens
40    "GITHUB_TOKEN",
41    "GH_TOKEN",
42    "GITHUB_PAT",
43    // NPM/Package registry tokens
44    "NPM_TOKEN",
45    "NPM_AUTH_TOKEN",
46    "CARGO_REGISTRY_TOKEN",
47    "PYPI_TOKEN",
48    // Database credentials
49    "DATABASE_URL",
50    "DB_PASSWORD",
51    "PGPASSWORD",
52    "MYSQL_PWD",
53    "REDIS_PASSWORD",
54    "MONGO_PASSWORD",
55    // SSH/GPG
56    "SSH_AUTH_SOCK",
57    "GPG_AGENT_INFO",
58    // Dynamic linker vars (security risk)
59    "LD_PRELOAD",
60    "LD_LIBRARY_PATH",
61    "LD_AUDIT",
62    "LD_DEBUG",
63    "LD_PROFILE",
64    "DYLD_INSERT_LIBRARIES",
65    "DYLD_LIBRARY_PATH",
66    "DYLD_FRAMEWORK_PATH",
67    "DYLD_FALLBACK_LIBRARY_PATH",
68    // Other sensitive vars
69    "VAULT_TOKEN",
70    "CONSUL_HTTP_TOKEN",
71    "DOCKER_AUTH_CONFIG",
72    "KUBECONFIG",
73    "KUBE_TOKEN",
74    "SLACK_TOKEN",
75    "SLACK_BOT_TOKEN",
76    "DISCORD_TOKEN",
77    "TELEGRAM_BOT_TOKEN",
78];
79
80/// Environment variables that should always be preserved.
81pub const PRESERVED_ENV_VARS: &[&str] = &[
82    // Basic shell environment
83    "PATH",
84    "HOME",
85    "USER",
86    "SHELL",
87    "TERM",
88    "LANG",
89    "LC_ALL",
90    "LC_CTYPE",
91    "TZ",
92    // XDG directories
93    "XDG_CONFIG_HOME",
94    "XDG_DATA_HOME",
95    "XDG_CACHE_HOME",
96    "XDG_RUNTIME_DIR",
97    // Editor preferences (not sensitive)
98    "EDITOR",
99    "VISUAL",
100    "PAGER",
101    // Build tool paths
102    "CARGO_HOME",
103    "RUSTUP_HOME",
104    "GOPATH",
105    "GOROOT",
106    "JAVA_HOME",
107    "PYTHON",
108    "PYTHONPATH",
109    "NODE_PATH",
110    // Terminal capabilities
111    "COLORTERM",
112    "FORCE_COLOR",
113    "NO_COLOR",
114    "CLICOLOR",
115    "CLICOLOR_FORCE",
116    // Temp directories
117    "TMPDIR",
118    "TEMP",
119    "TMP",
120];
121
122/// Sandbox environment markers set for child processes.
123pub const VTCODE_SANDBOX_ACTIVE: &str = "VTCODE_SANDBOX_ACTIVE";
124pub const VTCODE_SANDBOX_NETWORK_DISABLED: &str = "VTCODE_SANDBOX_NETWORK_DISABLED";
125pub const VTCODE_SANDBOX_TYPE: &str = "VTCODE_SANDBOX_TYPE";
126pub const VTCODE_SANDBOX_WRITABLE_ROOTS: &str = "VTCODE_SANDBOX_WRITABLE_ROOTS";
127
128/// Build a sanitized environment for sandboxed child processes.
129///
130/// Implements the Codex pattern: "Completely clear the environment and rebuild it
131/// with only the variables you actually want."
132#[expect(
133    unused_results,
134    reason = "Environment construction intentionally ignores prior values while writing the sanitized snapshot."
135)]
136pub fn build_sanitized_env(
137    current_env: &HashMap<String, String>,
138    sandbox_active: bool,
139    network_disabled: bool,
140    sandbox_type: &str,
141    writable_roots: &[&Path],
142) -> HashMap<String, String> {
143    let mut sanitized = HashMap::new();
144
145    // Copy only preserved environment variables
146    for key in PRESERVED_ENV_VARS {
147        if let Some(value) = current_env.get(*key) {
148            sanitized.insert(key.to_string(), value.clone());
149        }
150    }
151
152    // Add sandbox markers so downstream tools know what's happening
153    if sandbox_active {
154        sanitized.insert(VTCODE_SANDBOX_ACTIVE.to_string(), "1".to_string());
155        sanitized.insert(VTCODE_SANDBOX_TYPE.to_string(), sandbox_type.to_string());
156
157        if network_disabled {
158            sanitized.insert(VTCODE_SANDBOX_NETWORK_DISABLED.to_string(), "1".to_string());
159        }
160
161        if !writable_roots.is_empty() {
162            let roots: Vec<String> = writable_roots.iter().map(|p| p.display().to_string()).collect();
163            sanitized.insert(VTCODE_SANDBOX_WRITABLE_ROOTS.to_string(), roots.join(":"));
164        }
165    }
166
167    sanitized
168}
169
170/// Check if an environment variable should be filtered.
171pub fn should_filter_env_var(key: &str) -> bool {
172    let key = key.to_ascii_uppercase();
173    FILTERED_ENV_VARS.contains(&key.as_str())
174        || key.starts_with("AWS_")
175        || key.starts_with("AZURE_")
176        || key.starts_with("GOOGLE_")
177        || key.starts_with("GCP_")
178        || key.starts_with("CLOUDSDK_")
179        || key.starts_with("LD_")
180        || key.starts_with("DYLD_")
181        || matches!(key.as_str(), "TOKEN" | "SECRET" | "PASSWORD" | "PASS" | "PWD" | "CREDENTIALS")
182        || key.ends_with("_TOKEN")
183        || key.ends_with("_KEY")
184        || key.ends_with("_SECRET")
185        || key.ends_with("_PASS")
186        || key.ends_with("_PWD")
187        || key.ends_with("_PASSWORD")
188        || key.ends_with("_CREDENTIALS")
189}
190
191/// Filter sensitive environment variables from an existing map.
192///
193/// Less aggressive than `build_sanitized_env` - preserves most vars but removes known sensitive ones.
194pub fn filter_sensitive_env(env: &HashMap<String, String>) -> HashMap<String, String> {
195    env.iter()
196        .filter(|(k, _)| !should_filter_env_var(k))
197        .map(|(k, v)| (k.clone(), v.clone()))
198        .collect()
199}
200
201/// Set up parent death signal on Linux.
202///
203/// "Ensures sandboxed children die if the main process gets killed -
204/// you don't want orphaned processes running around."
205///
206/// Uses SIGTERM for graceful shutdown. Includes parent PID check to avoid
207/// race condition where parent exits between fork and exec.
208#[cfg(target_os = "linux")]
209pub fn setup_parent_death_signal() -> std::io::Result<()> {
210    setup_parent_death_signal_with_check(nix::unistd::getppid())
211}
212
213/// Set up parent death signal with explicit parent PID check.
214///
215/// This variant should be used in pre_exec hooks where the parent PID
216/// is captured before spawn to avoid race conditions.
217#[cfg(target_os = "linux")]
218pub fn setup_parent_death_signal_with_check(expected_parent_pid: nix::unistd::Pid) -> std::io::Result<()> {
219    use nix::sys::prctl;
220    use nix::sys::signal::{Signal, raise};
221    use std::io::Error;
222
223    // Use SIGTERM for graceful shutdown (allows cleanup handlers to run)
224    prctl::set_pdeathsig(Some(Signal::SIGTERM))
225        .map_err(|e| Error::other(format!("prctl(PR_SET_PDEATHSIG) failed: {e}")))?;
226
227    // Re-check parent PID to catch race condition where parent exited between
228    // fork and this prctl call. If parent changed, self-terminate immediately.
229    if nix::unistd::getppid() != expected_parent_pid {
230        let _ = raise(Signal::SIGTERM);
231    }
232
233    Ok(())
234}
235
236#[cfg(not(target_os = "linux"))]
237pub fn setup_parent_death_signal() -> std::io::Result<()> {
238    Ok(())
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    const TEST_API_KEY_VALUE: &str = "test-openai-key";
246
247    #[test]
248    fn test_should_filter_sensitive_vars() {
249        assert!(should_filter_env_var("OPENAI_API_KEY"));
250        assert!(should_filter_env_var("AWS_SECRET_ACCESS_KEY"));
251        assert!(should_filter_env_var("GITHUB_TOKEN"));
252        assert!(should_filter_env_var("LD_PRELOAD"));
253        assert!(should_filter_env_var("DYLD_INSERT_LIBRARIES"));
254        assert!(should_filter_env_var("MY_CUSTOM_TOKEN"));
255        assert!(should_filter_env_var("token"));
256        assert!(should_filter_env_var("CloudSDK_AUTH_CREDENTIAL_FILE_OVERRIDE"));
257        assert!(should_filter_env_var("MY_CUSTOM_PASS"));
258        assert!(should_filter_env_var("MYSQL_PWD"));
259        assert!(should_filter_env_var("DATABASE_PASSWORD"));
260
261        assert!(!should_filter_env_var("PATH"));
262        assert!(!should_filter_env_var("HOME"));
263        assert!(!should_filter_env_var("TERM"));
264    }
265
266    #[test]
267    fn filters_sensitive_names_case_insensitively() {
268        assert!(should_filter_env_var("openai_api_key"));
269        assert!(should_filter_env_var("Aws_SECRET_ACCESS_KEY"));
270        assert!(should_filter_env_var("custom_access_token"));
271        assert!(should_filter_env_var("dyld_insert_libraries"));
272        assert!(!should_filter_env_var("PROJECT_NAME"));
273    }
274
275    #[test]
276    fn test_build_sanitized_env() {
277        let mut current = HashMap::new();
278        drop(current.insert("PATH".to_string(), "/usr/bin".to_string()));
279        drop(current.insert("HOME".to_string(), "/home/user".to_string()));
280        drop(current.insert("OPENAI_API_KEY".to_string(), TEST_API_KEY_VALUE.to_string()));
281        drop(current.insert("RANDOM_VAR".to_string(), "value".to_string()));
282
283        let sanitized = build_sanitized_env(&current, true, true, "MacosSeatbelt", &[]);
284
285        // PATH and HOME should be preserved
286        assert_eq!(sanitized.get("PATH"), Some(&"/usr/bin".to_string()));
287        assert_eq!(sanitized.get("HOME"), Some(&"/home/user".to_string()));
288
289        // API key should NOT be present (not in preserved list)
290        assert!(!sanitized.contains_key("OPENAI_API_KEY"));
291
292        // Random var should NOT be present (not in preserved list)
293        assert!(!sanitized.contains_key("RANDOM_VAR"));
294
295        // Sandbox markers should be set
296        assert_eq!(sanitized.get(VTCODE_SANDBOX_ACTIVE), Some(&"1".to_string()));
297        assert_eq!(sanitized.get(VTCODE_SANDBOX_NETWORK_DISABLED), Some(&"1".to_string()));
298        assert_eq!(sanitized.get(VTCODE_SANDBOX_TYPE), Some(&"MacosSeatbelt".to_string()));
299    }
300
301    #[test]
302    fn test_filter_sensitive_env() {
303        let mut env = HashMap::new();
304        drop(env.insert("PATH".to_string(), "/usr/bin".to_string()));
305        drop(env.insert("OPENAI_API_KEY".to_string(), TEST_API_KEY_VALUE.to_string()));
306        drop(env.insert("MY_VAR".to_string(), "value".to_string()));
307        drop(env.insert("AWS_ACCESS_KEY_ID".to_string(), "AKIA...".to_string()));
308        drop(env.insert("CUSTOM_PASS".to_string(), "let-me-in".to_string()));
309        drop(env.insert("SERVICE_PWD".to_string(), "super-secret".to_string()));
310
311        let filtered = filter_sensitive_env(&env);
312
313        assert!(filtered.contains_key("PATH"));
314        assert!(filtered.contains_key("MY_VAR"));
315        assert!(!filtered.contains_key("OPENAI_API_KEY"));
316        assert!(!filtered.contains_key("AWS_ACCESS_KEY_ID"));
317        assert!(!filtered.contains_key("CUSTOM_PASS"));
318        assert!(!filtered.contains_key("SERVICE_PWD"));
319    }
320}