1use hashbrown::HashMap;
9use std::path::Path;
10
11pub const FILTERED_ENV_VARS: &[&str] = &[
16 "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 "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_TOKEN",
43 "GH_TOKEN",
44 "GITHUB_PAT",
45 "NPM_TOKEN",
47 "NPM_AUTH_TOKEN",
48 "CARGO_REGISTRY_TOKEN",
49 "PYPI_TOKEN",
50 "DATABASE_URL",
52 "DB_PASSWORD",
53 "PGPASSWORD",
54 "MYSQL_PWD",
55 "REDIS_PASSWORD",
56 "MONGO_PASSWORD",
57 "SSH_AUTH_SOCK",
59 "GPG_AGENT_INFO",
60 "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 "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
82pub const PRESERVED_ENV_VARS: &[&str] = &[
84 "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_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 "VTCODE_CONFIG",
111 "VTCODE_CONFIG_PATH",
112 "VTCODE_DATA",
113 "VTCODE_HOME",
114 "CODEX_HOME",
116 "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 "CARGO_HOME",
131 "RUSTUP_HOME",
132 "GOPATH",
133 "GOROOT",
134 "JAVA_HOME",
135 "PYTHON",
136 "PYTHONUNBUFFERED",
140 "GIT_TERMINAL_PROMPT",
141 "COLORTERM",
143 "FORCE_COLOR",
144 "NO_COLOR",
145 "CLICOLOR",
146 "CLICOLOR_FORCE",
147 "TMPDIR",
149 "TEMP",
150 "TMP",
151];
152
153pub const VTCODE_SANDBOX_ACTIVE: &str = "VTCODE_SANDBOX_ACTIVE";
155pub const VTCODE_SANDBOX_NETWORK_DISABLED: &str = "VTCODE_SANDBOX_NETWORK_DISABLED";
156pub const VTCODE_SANDBOX_TYPE: &str = "VTCODE_SANDBOX_TYPE";
157pub const VTCODE_SANDBOX_WRITABLE_ROOTS: &str = "VTCODE_SANDBOX_WRITABLE_ROOTS";
158
159#[expect(
164 unused_results,
165 reason = "Environment construction intentionally ignores prior values while writing the sanitized snapshot."
166)]
167pub fn build_sanitized_env(
168 current_env: &HashMap<String, String>,
169 sandbox_active: bool,
170 network_disabled: bool,
171 sandbox_type: &str,
172 writable_roots: &[&Path],
173) -> HashMap<String, String> {
174 let mut sanitized = HashMap::new();
175
176 for key in PRESERVED_ENV_VARS {
178 let value = current_env.get(*key).or_else(|| {
179 cfg!(windows)
180 .then(|| current_env.iter().find(|(name, _)| name.eq_ignore_ascii_case(key)))
181 .flatten()
182 .map(|(_, value)| value)
183 });
184 if let Some(value) = value {
185 sanitized.insert(key.to_string(), value.clone());
186 }
187 }
188
189 if sandbox_active {
191 sanitized.insert(VTCODE_SANDBOX_ACTIVE.to_string(), "1".to_string());
192 sanitized.insert(VTCODE_SANDBOX_TYPE.to_string(), sandbox_type.to_string());
193
194 if network_disabled {
195 sanitized.insert(VTCODE_SANDBOX_NETWORK_DISABLED.to_string(), "1".to_string());
196 }
197
198 if !writable_roots.is_empty() {
199 let roots: Vec<String> = writable_roots.iter().map(|p| p.display().to_string()).collect();
200 sanitized.insert(VTCODE_SANDBOX_WRITABLE_ROOTS.to_string(), roots.join(":"));
201 }
202 }
203
204 sanitized
205}
206
207pub fn should_filter_env_var(key: &str) -> bool {
209 let key = key.to_ascii_uppercase();
210 FILTERED_ENV_VARS.contains(&key.as_str())
211 || key.starts_with("AWS_")
212 || key.starts_with("AZURE_")
213 || key.starts_with("GOOGLE_")
214 || key.starts_with("GCP_")
215 || key.starts_with("CLOUDSDK_")
216 || key.starts_with("LD_")
217 || key.starts_with("DYLD_")
218 || matches!(key.as_str(), "TOKEN" | "SECRET" | "PASSWORD" | "PASS" | "PWD" | "CREDENTIALS")
219 || key.ends_with("_TOKEN")
220 || key.ends_with("_KEY")
221 || key.ends_with("_SECRET")
222 || key.ends_with("_PASS")
223 || key.ends_with("_PWD")
224 || key.ends_with("_PASSWORD")
225 || key.ends_with("_CREDENTIALS")
226}
227
228pub fn filter_sensitive_env(env: &HashMap<String, String>) -> HashMap<String, String> {
232 env.iter()
233 .filter(|(k, _)| !should_filter_env_var(k))
234 .map(|(k, v)| (k.clone(), v.clone()))
235 .collect()
236}
237
238#[cfg(target_os = "linux")]
246pub fn setup_parent_death_signal() -> std::io::Result<()> {
247 setup_parent_death_signal_with_check(nix::unistd::getppid())
248}
249
250#[cfg(target_os = "linux")]
255pub fn setup_parent_death_signal_with_check(expected_parent_pid: nix::unistd::Pid) -> std::io::Result<()> {
256 use nix::sys::prctl;
257 use nix::sys::signal::{Signal, raise};
258 use std::io::Error;
259
260 prctl::set_pdeathsig(Some(Signal::SIGTERM))
262 .map_err(|e| Error::other(format!("prctl(PR_SET_PDEATHSIG) failed: {e}")))?;
263
264 #[allow(
268 clippy::let_underscore_must_use,
269 reason = "best-effort self-signal during pdeathsig race"
270 )]
271 if nix::unistd::getppid() != expected_parent_pid {
272 let _ = raise(Signal::SIGTERM);
273 }
274
275 Ok(())
276}
277
278#[cfg(not(target_os = "linux"))]
279pub fn setup_parent_death_signal() -> std::io::Result<()> {
280 Ok(())
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 const TEST_API_KEY_VALUE: &str = "test-openai-key";
288
289 #[test]
290 fn test_should_filter_sensitive_vars() {
291 assert!(should_filter_env_var("OPENAI_API_KEY"));
292 assert!(should_filter_env_var("META_API_KEY"));
293 assert!(should_filter_env_var("AWS_SECRET_ACCESS_KEY"));
294 assert!(should_filter_env_var("GITHUB_TOKEN"));
295 assert!(should_filter_env_var("LD_PRELOAD"));
296 assert!(should_filter_env_var("DYLD_INSERT_LIBRARIES"));
297 assert!(should_filter_env_var("MY_CUSTOM_TOKEN"));
298 assert!(should_filter_env_var("token"));
299 assert!(should_filter_env_var("CloudSDK_AUTH_CREDENTIAL_FILE_OVERRIDE"));
300 assert!(should_filter_env_var("MY_CUSTOM_PASS"));
301 assert!(should_filter_env_var("MYSQL_PWD"));
302 assert!(should_filter_env_var("DATABASE_PASSWORD"));
303
304 assert!(!should_filter_env_var("PATH"));
305 assert!(!should_filter_env_var("HOME"));
306 assert!(!should_filter_env_var("TERM"));
307 }
308
309 #[test]
310 fn filters_sensitive_names_case_insensitively() {
311 assert!(should_filter_env_var("openai_api_key"));
312 assert!(should_filter_env_var("Aws_SECRET_ACCESS_KEY"));
313 assert!(should_filter_env_var("custom_access_token"));
314 assert!(should_filter_env_var("dyld_insert_libraries"));
315 assert!(!should_filter_env_var("PROJECT_NAME"));
316 }
317
318 #[test]
319 fn test_build_sanitized_env() {
320 let mut current = HashMap::new();
321 drop(current.insert("PATH".to_string(), "/usr/bin".to_string()));
322 drop(current.insert("HOME".to_string(), "/home/user".to_string()));
323 drop(current.insert("OPENAI_API_KEY".to_string(), TEST_API_KEY_VALUE.to_string()));
324 drop(current.insert("RANDOM_VAR".to_string(), "value".to_string()));
325 drop(current.insert("PYTHONUNBUFFERED".to_string(), "1".to_string()));
326 drop(current.insert("GIT_TERMINAL_PROMPT".to_string(), "0".to_string()));
327
328 let sanitized = build_sanitized_env(¤t, true, true, "MacosSeatbelt", &[]);
329
330 assert_eq!(sanitized.get("PATH"), Some(&"/usr/bin".to_string()));
332 assert_eq!(sanitized.get("HOME"), Some(&"/home/user".to_string()));
333
334 assert_eq!(sanitized.get("PYTHONUNBUFFERED"), Some(&"1".to_string()));
336 assert_eq!(sanitized.get("GIT_TERMINAL_PROMPT"), Some(&"0".to_string()));
337
338 assert!(!sanitized.contains_key("OPENAI_API_KEY"));
340
341 assert!(!sanitized.contains_key("RANDOM_VAR"));
343
344 assert_eq!(sanitized.get(VTCODE_SANDBOX_ACTIVE), Some(&"1".to_string()));
346 assert_eq!(sanitized.get(VTCODE_SANDBOX_NETWORK_DISABLED), Some(&"1".to_string()));
347 assert_eq!(sanitized.get(VTCODE_SANDBOX_TYPE), Some(&"MacosSeatbelt".to_string()));
348 }
349
350 #[test]
351 fn preserves_full_xdg_and_vtcode_path_environment_without_secrets() {
352 let mut current = HashMap::new();
353 for (key, value) in [
354 ("XDG_STATE_HOME", "/state"),
355 ("XDG_CONFIG_DIRS", "/etc/xdg:/opt/xdg"),
356 ("XDG_DATA_DIRS", "/usr/local/share:/usr/share"),
357 ("XDG_BIN_HOME", "/home/user/bin"),
358 ("VTCODE_CONFIG", "/config"),
359 ("VTCODE_CONFIG_PATH", "/config/explicit.toml"),
360 ("VTCODE_DATA", "/data"),
361 ("VTCODE_HOME", "/legacy"),
362 ("CODEX_HOME", "/codex"),
363 ("OPENAI_API_KEY", TEST_API_KEY_VALUE),
364 ("DATABASE_PASSWORD", "do-not-copy"),
365 ] {
366 drop(current.insert(key.to_string(), value.to_string()));
367 }
368
369 let sanitized = build_sanitized_env(¤t, false, false, "test", &[]);
370
371 for (key, value) in [
372 ("XDG_STATE_HOME", "/state"),
373 ("XDG_CONFIG_DIRS", "/etc/xdg:/opt/xdg"),
374 ("XDG_DATA_DIRS", "/usr/local/share:/usr/share"),
375 ("XDG_BIN_HOME", "/home/user/bin"),
376 ("VTCODE_CONFIG", "/config"),
377 ("VTCODE_CONFIG_PATH", "/config/explicit.toml"),
378 ("VTCODE_DATA", "/data"),
379 ("VTCODE_HOME", "/legacy"),
380 ("CODEX_HOME", "/codex"),
381 ] {
382 assert_eq!(sanitized.get(key), Some(&value.to_string()), "missing {key}");
383 }
384 assert!(!sanitized.contains_key("OPENAI_API_KEY"));
385 assert!(!sanitized.contains_key("DATABASE_PASSWORD"));
386 }
387
388 #[test]
389 fn test_filter_sensitive_env() {
390 let mut env = HashMap::new();
391 drop(env.insert("PATH".to_string(), "/usr/bin".to_string()));
392 drop(env.insert("OPENAI_API_KEY".to_string(), TEST_API_KEY_VALUE.to_string()));
393 drop(env.insert("MY_VAR".to_string(), "value".to_string()));
394 drop(env.insert("AWS_ACCESS_KEY_ID".to_string(), "AKIA...".to_string()));
395 drop(env.insert("CUSTOM_PASS".to_string(), "let-me-in".to_string()));
396 drop(env.insert("SERVICE_PWD".to_string(), "super-secret".to_string()));
397
398 let filtered = filter_sensitive_env(&env);
399
400 assert!(filtered.contains_key("PATH"));
401 assert!(filtered.contains_key("MY_VAR"));
402 assert!(!filtered.contains_key("OPENAI_API_KEY"));
403 assert!(!filtered.contains_key("AWS_ACCESS_KEY_ID"));
404 assert!(!filtered.contains_key("CUSTOM_PASS"));
405 assert!(!filtered.contains_key("SERVICE_PWD"));
406 }
407
408 #[test]
409 fn filters_adversarial_credential_and_loader_names() {
410 let mut env = HashMap::new();
411 for (name, value) in [
412 ("MODEL_API_KEY", "model-secret"),
413 ("META_API_KEY", "meta-secret"),
414 ("INSTRUCTION_SECRET", "prompt-secret"),
415 ("AWS_PROFILE", "production"),
416 ("LD_AUDIT", "audit.so"),
417 ("DYLD_LIBRARY_PATH", "/tmp/injected"),
418 ("SAFE_PROJECT_NAME", "vtcode"),
419 ] {
420 drop(env.insert(name.to_string(), value.to_string()));
421 }
422
423 let filtered = filter_sensitive_env(&env);
424 let rebuilt = build_sanitized_env(&env, true, false, "test", &[]);
425
426 for sensitive in [
427 "MODEL_API_KEY",
428 "META_API_KEY",
429 "INSTRUCTION_SECRET",
430 "AWS_PROFILE",
431 "LD_AUDIT",
432 "DYLD_LIBRARY_PATH",
433 ] {
434 assert!(!filtered.contains_key(sensitive), "sensitive variable survived filtering: {sensitive}");
435 assert!(!rebuilt.contains_key(sensitive), "sensitive variable survived rebuilding: {sensitive}");
436 }
437 assert_eq!(filtered.get("SAFE_PROJECT_NAME"), Some(&"vtcode".to_string()));
438 }
439}