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    "META_API_KEY",
23    "MODEL_API_KEY",
24    "OPENROUTER_API_KEY",
25    "GROQ_API_KEY",
26    "MISTRAL_API_KEY",
27    "COHERE_API_KEY",
28    "AZURE_OPENAI_API_KEY",
29    "HUGGINGFACE_API_KEY",
30    "HF_TOKEN",
31    // Cloud provider credentials
32    "AWS_ACCESS_KEY_ID",
33    "AWS_SECRET_ACCESS_KEY",
34    "AWS_SESSION_TOKEN",
35    "GOOGLE_APPLICATION_CREDENTIALS",
36    "GOOGLE_CLOUD_PROJECT",
37    "AZURE_CLIENT_ID",
38    "AZURE_CLIENT_SECRET",
39    "AZURE_TENANT_ID",
40    "AZURE_SUBSCRIPTION_ID",
41    // GitHub tokens
42    "GITHUB_TOKEN",
43    "GH_TOKEN",
44    "GITHUB_PAT",
45    // NPM/Package registry tokens
46    "NPM_TOKEN",
47    "NPM_AUTH_TOKEN",
48    "CARGO_REGISTRY_TOKEN",
49    "PYPI_TOKEN",
50    // Database credentials
51    "DATABASE_URL",
52    "DB_PASSWORD",
53    "PGPASSWORD",
54    "MYSQL_PWD",
55    "REDIS_PASSWORD",
56    "MONGO_PASSWORD",
57    // SSH/GPG
58    "SSH_AUTH_SOCK",
59    "GPG_AGENT_INFO",
60    // Dynamic linker vars (security risk)
61    "LD_PRELOAD",
62    "LD_LIBRARY_PATH",
63    "LD_AUDIT",
64    "LD_DEBUG",
65    "LD_PROFILE",
66    "DYLD_INSERT_LIBRARIES",
67    "DYLD_LIBRARY_PATH",
68    "DYLD_FRAMEWORK_PATH",
69    "DYLD_FALLBACK_LIBRARY_PATH",
70    // Other sensitive vars
71    "VAULT_TOKEN",
72    "CONSUL_HTTP_TOKEN",
73    "DOCKER_AUTH_CONFIG",
74    "KUBECONFIG",
75    "KUBE_TOKEN",
76    "SLACK_TOKEN",
77    "SLACK_BOT_TOKEN",
78    "DISCORD_TOKEN",
79    "TELEGRAM_BOT_TOKEN",
80];
81
82/// Environment variables that should always be preserved.
83pub const PRESERVED_ENV_VARS: &[&str] = &[
84    // Basic shell environment
85    "PATH",
86    "SystemRoot",
87    "SYSTEMROOT",
88    "WINDIR",
89    "COMSPEC",
90    "PATHEXT",
91    "USERPROFILE",
92    "HOME",
93    "USER",
94    "SHELL",
95    "TERM",
96    "LANG",
97    "LC_ALL",
98    "LC_CTYPE",
99    "TZ",
100    // XDG directories
101    "XDG_CONFIG_HOME",
102    "XDG_CONFIG_DIRS",
103    "XDG_DATA_HOME",
104    "XDG_DATA_DIRS",
105    "XDG_BIN_HOME",
106    "XDG_STATE_HOME",
107    "XDG_CACHE_HOME",
108    "XDG_RUNTIME_DIR",
109    // VT Code path overrides
110    "VTCODE_CONFIG",
111    "VTCODE_CONFIG_PATH",
112    "VTCODE_DATA",
113    "VTCODE_HOME",
114    // External Codex compatibility root
115    "CODEX_HOME",
116    // Editor preferences (not sensitive)
117    "EDITOR",
118    "VISUAL",
119    "PAGER",
120    "GIT_PAGER",
121    "LESS",
122    "COLUMNS",
123    "LINES",
124    "WORKSPACE_DIR",
125    "CLICOLOR",
126    "CLICOLOR_FORCE",
127    "LS_COLORS",
128    "CARGO_TERM_COLOR",
129    // Build tool paths
130    "CARGO_HOME",
131    "RUSTUP_HOME",
132    "GOPATH",
133    "GOROOT",
134    "JAVA_HOME",
135    "PYTHON",
136    "PYTHONPATH",
137    "NODE_PATH",
138    // Terminal capabilities
139    "COLORTERM",
140    "FORCE_COLOR",
141    "NO_COLOR",
142    "CLICOLOR",
143    "CLICOLOR_FORCE",
144    // Temp directories
145    "TMPDIR",
146    "TEMP",
147    "TMP",
148];
149
150/// Sandbox environment markers set for child processes.
151pub const VTCODE_SANDBOX_ACTIVE: &str = "VTCODE_SANDBOX_ACTIVE";
152pub const VTCODE_SANDBOX_NETWORK_DISABLED: &str = "VTCODE_SANDBOX_NETWORK_DISABLED";
153pub const VTCODE_SANDBOX_TYPE: &str = "VTCODE_SANDBOX_TYPE";
154pub const VTCODE_SANDBOX_WRITABLE_ROOTS: &str = "VTCODE_SANDBOX_WRITABLE_ROOTS";
155
156/// Build a sanitized environment for sandboxed child processes.
157///
158/// Implements the Codex pattern: "Completely clear the environment and rebuild it
159/// with only the variables you actually want."
160#[expect(
161    unused_results,
162    reason = "Environment construction intentionally ignores prior values while writing the sanitized snapshot."
163)]
164pub fn build_sanitized_env(
165    current_env: &HashMap<String, String>,
166    sandbox_active: bool,
167    network_disabled: bool,
168    sandbox_type: &str,
169    writable_roots: &[&Path],
170) -> HashMap<String, String> {
171    let mut sanitized = HashMap::new();
172
173    // Copy only preserved environment variables
174    for key in PRESERVED_ENV_VARS {
175        let value = current_env.get(*key).or_else(|| {
176            cfg!(windows)
177                .then(|| current_env.iter().find(|(name, _)| name.eq_ignore_ascii_case(key)))
178                .flatten()
179                .map(|(_, value)| value)
180        });
181        if let Some(value) = value {
182            sanitized.insert(key.to_string(), value.clone());
183        }
184    }
185
186    // Add sandbox markers so downstream tools know what's happening
187    if sandbox_active {
188        sanitized.insert(VTCODE_SANDBOX_ACTIVE.to_string(), "1".to_string());
189        sanitized.insert(VTCODE_SANDBOX_TYPE.to_string(), sandbox_type.to_string());
190
191        if network_disabled {
192            sanitized.insert(VTCODE_SANDBOX_NETWORK_DISABLED.to_string(), "1".to_string());
193        }
194
195        if !writable_roots.is_empty() {
196            let roots: Vec<String> = writable_roots.iter().map(|p| p.display().to_string()).collect();
197            sanitized.insert(VTCODE_SANDBOX_WRITABLE_ROOTS.to_string(), roots.join(":"));
198        }
199    }
200
201    sanitized
202}
203
204/// Check if an environment variable should be filtered.
205pub fn should_filter_env_var(key: &str) -> bool {
206    let key = key.to_ascii_uppercase();
207    FILTERED_ENV_VARS.contains(&key.as_str())
208        || key.starts_with("AWS_")
209        || key.starts_with("AZURE_")
210        || key.starts_with("GOOGLE_")
211        || key.starts_with("GCP_")
212        || key.starts_with("CLOUDSDK_")
213        || key.starts_with("LD_")
214        || key.starts_with("DYLD_")
215        || matches!(key.as_str(), "TOKEN" | "SECRET" | "PASSWORD" | "PASS" | "PWD" | "CREDENTIALS")
216        || key.ends_with("_TOKEN")
217        || key.ends_with("_KEY")
218        || key.ends_with("_SECRET")
219        || key.ends_with("_PASS")
220        || key.ends_with("_PWD")
221        || key.ends_with("_PASSWORD")
222        || key.ends_with("_CREDENTIALS")
223}
224
225/// Filter sensitive environment variables from an existing map.
226///
227/// Less aggressive than `build_sanitized_env` - preserves most vars but removes known sensitive ones.
228pub fn filter_sensitive_env(env: &HashMap<String, String>) -> HashMap<String, String> {
229    env.iter()
230        .filter(|(k, _)| !should_filter_env_var(k))
231        .map(|(k, v)| (k.clone(), v.clone()))
232        .collect()
233}
234
235/// Set up parent death signal on Linux.
236///
237/// "Ensures sandboxed children die if the main process gets killed -
238/// you don't want orphaned processes running around."
239///
240/// Uses SIGTERM for graceful shutdown. Includes parent PID check to avoid
241/// race condition where parent exits between fork and exec.
242#[cfg(target_os = "linux")]
243pub fn setup_parent_death_signal() -> std::io::Result<()> {
244    setup_parent_death_signal_with_check(nix::unistd::getppid())
245}
246
247/// Set up parent death signal with explicit parent PID check.
248///
249/// This variant should be used in pre_exec hooks where the parent PID
250/// is captured before spawn to avoid race conditions.
251#[cfg(target_os = "linux")]
252pub fn setup_parent_death_signal_with_check(expected_parent_pid: nix::unistd::Pid) -> std::io::Result<()> {
253    use nix::sys::prctl;
254    use nix::sys::signal::{Signal, raise};
255    use std::io::Error;
256
257    // Use SIGTERM for graceful shutdown (allows cleanup handlers to run)
258    prctl::set_pdeathsig(Some(Signal::SIGTERM))
259        .map_err(|e| Error::other(format!("prctl(PR_SET_PDEATHSIG) failed: {e}")))?;
260
261    // Re-check parent PID to catch race condition where parent exited between
262    // fork and this prctl call. If parent changed, self-terminate immediately.
263    // Signal delivery here is deliberately best-effort: nothing can recover it.
264    #[allow(
265        clippy::let_underscore_must_use,
266        reason = "best-effort self-signal during pdeathsig race"
267    )]
268    if nix::unistd::getppid() != expected_parent_pid {
269        let _ = raise(Signal::SIGTERM);
270    }
271
272    Ok(())
273}
274
275#[cfg(not(target_os = "linux"))]
276pub fn setup_parent_death_signal() -> std::io::Result<()> {
277    Ok(())
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    const TEST_API_KEY_VALUE: &str = "test-openai-key";
285
286    #[test]
287    fn test_should_filter_sensitive_vars() {
288        assert!(should_filter_env_var("OPENAI_API_KEY"));
289        assert!(should_filter_env_var("META_API_KEY"));
290        assert!(should_filter_env_var("AWS_SECRET_ACCESS_KEY"));
291        assert!(should_filter_env_var("GITHUB_TOKEN"));
292        assert!(should_filter_env_var("LD_PRELOAD"));
293        assert!(should_filter_env_var("DYLD_INSERT_LIBRARIES"));
294        assert!(should_filter_env_var("MY_CUSTOM_TOKEN"));
295        assert!(should_filter_env_var("token"));
296        assert!(should_filter_env_var("CloudSDK_AUTH_CREDENTIAL_FILE_OVERRIDE"));
297        assert!(should_filter_env_var("MY_CUSTOM_PASS"));
298        assert!(should_filter_env_var("MYSQL_PWD"));
299        assert!(should_filter_env_var("DATABASE_PASSWORD"));
300
301        assert!(!should_filter_env_var("PATH"));
302        assert!(!should_filter_env_var("HOME"));
303        assert!(!should_filter_env_var("TERM"));
304    }
305
306    #[test]
307    fn filters_sensitive_names_case_insensitively() {
308        assert!(should_filter_env_var("openai_api_key"));
309        assert!(should_filter_env_var("Aws_SECRET_ACCESS_KEY"));
310        assert!(should_filter_env_var("custom_access_token"));
311        assert!(should_filter_env_var("dyld_insert_libraries"));
312        assert!(!should_filter_env_var("PROJECT_NAME"));
313    }
314
315    #[test]
316    fn test_build_sanitized_env() {
317        let mut current = HashMap::new();
318        drop(current.insert("PATH".to_string(), "/usr/bin".to_string()));
319        drop(current.insert("HOME".to_string(), "/home/user".to_string()));
320        drop(current.insert("OPENAI_API_KEY".to_string(), TEST_API_KEY_VALUE.to_string()));
321        drop(current.insert("RANDOM_VAR".to_string(), "value".to_string()));
322
323        let sanitized = build_sanitized_env(&current, true, true, "MacosSeatbelt", &[]);
324
325        // PATH and HOME should be preserved
326        assert_eq!(sanitized.get("PATH"), Some(&"/usr/bin".to_string()));
327        assert_eq!(sanitized.get("HOME"), Some(&"/home/user".to_string()));
328
329        // API key should NOT be present (not in preserved list)
330        assert!(!sanitized.contains_key("OPENAI_API_KEY"));
331
332        // Random var should NOT be present (not in preserved list)
333        assert!(!sanitized.contains_key("RANDOM_VAR"));
334
335        // Sandbox markers should be set
336        assert_eq!(sanitized.get(VTCODE_SANDBOX_ACTIVE), Some(&"1".to_string()));
337        assert_eq!(sanitized.get(VTCODE_SANDBOX_NETWORK_DISABLED), Some(&"1".to_string()));
338        assert_eq!(sanitized.get(VTCODE_SANDBOX_TYPE), Some(&"MacosSeatbelt".to_string()));
339    }
340
341    #[test]
342    fn preserves_full_xdg_and_vtcode_path_environment_without_secrets() {
343        let mut current = HashMap::new();
344        for (key, value) in [
345            ("XDG_STATE_HOME", "/state"),
346            ("XDG_CONFIG_DIRS", "/etc/xdg:/opt/xdg"),
347            ("XDG_DATA_DIRS", "/usr/local/share:/usr/share"),
348            ("XDG_BIN_HOME", "/home/user/bin"),
349            ("VTCODE_CONFIG", "/config"),
350            ("VTCODE_CONFIG_PATH", "/config/explicit.toml"),
351            ("VTCODE_DATA", "/data"),
352            ("VTCODE_HOME", "/legacy"),
353            ("CODEX_HOME", "/codex"),
354            ("OPENAI_API_KEY", TEST_API_KEY_VALUE),
355            ("DATABASE_PASSWORD", "do-not-copy"),
356        ] {
357            drop(current.insert(key.to_string(), value.to_string()));
358        }
359
360        let sanitized = build_sanitized_env(&current, false, false, "test", &[]);
361
362        for (key, value) in [
363            ("XDG_STATE_HOME", "/state"),
364            ("XDG_CONFIG_DIRS", "/etc/xdg:/opt/xdg"),
365            ("XDG_DATA_DIRS", "/usr/local/share:/usr/share"),
366            ("XDG_BIN_HOME", "/home/user/bin"),
367            ("VTCODE_CONFIG", "/config"),
368            ("VTCODE_CONFIG_PATH", "/config/explicit.toml"),
369            ("VTCODE_DATA", "/data"),
370            ("VTCODE_HOME", "/legacy"),
371            ("CODEX_HOME", "/codex"),
372        ] {
373            assert_eq!(sanitized.get(key), Some(&value.to_string()), "missing {key}");
374        }
375        assert!(!sanitized.contains_key("OPENAI_API_KEY"));
376        assert!(!sanitized.contains_key("DATABASE_PASSWORD"));
377    }
378
379    #[test]
380    fn test_filter_sensitive_env() {
381        let mut env = HashMap::new();
382        drop(env.insert("PATH".to_string(), "/usr/bin".to_string()));
383        drop(env.insert("OPENAI_API_KEY".to_string(), TEST_API_KEY_VALUE.to_string()));
384        drop(env.insert("MY_VAR".to_string(), "value".to_string()));
385        drop(env.insert("AWS_ACCESS_KEY_ID".to_string(), "AKIA...".to_string()));
386        drop(env.insert("CUSTOM_PASS".to_string(), "let-me-in".to_string()));
387        drop(env.insert("SERVICE_PWD".to_string(), "super-secret".to_string()));
388
389        let filtered = filter_sensitive_env(&env);
390
391        assert!(filtered.contains_key("PATH"));
392        assert!(filtered.contains_key("MY_VAR"));
393        assert!(!filtered.contains_key("OPENAI_API_KEY"));
394        assert!(!filtered.contains_key("AWS_ACCESS_KEY_ID"));
395        assert!(!filtered.contains_key("CUSTOM_PASS"));
396        assert!(!filtered.contains_key("SERVICE_PWD"));
397    }
398
399    #[test]
400    fn filters_adversarial_credential_and_loader_names() {
401        let mut env = HashMap::new();
402        for (name, value) in [
403            ("MODEL_API_KEY", "model-secret"),
404            ("META_API_KEY", "meta-secret"),
405            ("INSTRUCTION_SECRET", "prompt-secret"),
406            ("AWS_PROFILE", "production"),
407            ("LD_AUDIT", "audit.so"),
408            ("DYLD_LIBRARY_PATH", "/tmp/injected"),
409            ("SAFE_PROJECT_NAME", "vtcode"),
410        ] {
411            drop(env.insert(name.to_string(), value.to_string()));
412        }
413
414        let filtered = filter_sensitive_env(&env);
415        let rebuilt = build_sanitized_env(&env, true, false, "test", &[]);
416
417        for sensitive in [
418            "MODEL_API_KEY",
419            "META_API_KEY",
420            "INSTRUCTION_SECRET",
421            "AWS_PROFILE",
422            "LD_AUDIT",
423            "DYLD_LIBRARY_PATH",
424        ] {
425            assert!(!filtered.contains_key(sensitive), "sensitive variable survived filtering: {sensitive}");
426            assert!(!rebuilt.contains_key(sensitive), "sensitive variable survived rebuilding: {sensitive}");
427        }
428        assert_eq!(filtered.get("SAFE_PROJECT_NAME"), Some(&"vtcode".to_string()));
429    }
430}