返回 CodeWhale
child_env.rs
根目录 / crates / tui / src / child_env.rs
1 //! Sanitized environment handling for child processes.
2
3 use std::collections::HashMap;
4 use std::ffi::{OsStr, OsString};
5
6 #[cfg(windows)]
7 use std::os::windows::ffi::{OsStrExt, OsStringExt};
8 #[cfg(windows)]
9 use windows::Win32::Foundation::{ERROR_MORE_DATA, ERROR_NO_MORE_ITEMS, ERROR_SUCCESS};
10 #[cfg(windows)]
11 use windows::Win32::System::Environment::ExpandEnvironmentStringsW;
12 #[cfg(windows)]
13 use windows::Win32::System::Registry::{
14 HKEY, HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, KEY_READ, REG_EXPAND_SZ, REG_SZ, REG_VALUE_TYPE,
15 RegCloseKey, RegEnumValueW, RegOpenKeyExW,
16 };
17 #[cfg(windows)]
18 use windows::core::{PCWSTR, PWSTR};
19
20 /// Convert a string env map into owned OS strings for child env helpers.
21 pub fn string_map_env(
22 env: &HashMap<String, String>,
23 ) -> impl Iterator<Item = (OsString, OsString)> + '_ {
24 env.iter()
25 .map(|(key, value)| (OsString::from(key), OsString::from(value)))
26 }
27
28 /// Return the environment for a child process after dropping parent secrets.
29 ///
30 /// `overrides` are trusted call-site values, such as sandbox markers, hook
31 /// variables, MCP server config, or RLM context path. They are applied after the
32 /// parent allowlist so explicit values win.
33 pub fn sanitized_child_env<I, K, V>(overrides: I) -> Vec<(OsString, OsString)>
34 where
35 I: IntoIterator<Item = (K, V)>,
36 K: AsRef<OsStr>,
37 V: AsRef<OsStr>,
38 {
39 let mut env = Vec::new();
40 #[cfg(windows)]
41 append_sanitized_child_env_candidates(&mut env, windows_registry_env_vars());
42 for (key, value) in std::env::vars_os() {
43 append_sanitized_child_env_candidate(&mut env, key, value);
44 }
45 for (key, value) in overrides {
46 upsert_env(
47 &mut env,
48 key.as_ref().to_os_string(),
49 value.as_ref().to_os_string(),
50 );
51 }
52 #[cfg(windows)]
53 fill_windows_common_program_files(&mut env);
54 env
55 }
56
57 pub fn apply_to_command<I, K, V>(cmd: &mut std::process::Command, overrides: I)
58 where
59 I: IntoIterator<Item = (K, V)>,
60 K: AsRef<OsStr>,
61 V: AsRef<OsStr>,
62 {
63 cmd.env_clear();
64 for (key, value) in sanitized_child_env(overrides) {
65 cmd.env(key, value);
66 }
67 }
68
69 pub fn apply_to_tokio_command<I, K, V>(cmd: &mut tokio::process::Command, overrides: I)
70 where
71 I: IntoIterator<Item = (K, V)>,
72 K: AsRef<OsStr>,
73 V: AsRef<OsStr>,
74 {
75 cmd.env_clear();
76 for (key, value) in sanitized_child_env(overrides) {
77 cmd.env(key, value);
78 }
79 }
80
81 #[cfg(not(target_env = "ohos"))]
82 pub fn apply_to_pty_command<I, K, V>(cmd: &mut portable_pty::CommandBuilder, overrides: I)
83 where
84 I: IntoIterator<Item = (K, V)>,
85 K: AsRef<OsStr>,
86 V: AsRef<OsStr>,
87 {
88 cmd.env_clear();
89 for (key, value) in sanitized_child_env(overrides) {
90 cmd.env(key, value);
91 }
92 }
93
94 /// Build the sanitized child environment used for MCP stdio servers.
95 ///
96 /// MCP stdio servers are user-configured integrations declared in
97 /// `~/.deepseek/mcp.json` (or equivalent). They are not arbitrary processes
98 /// the agent decided to launch on its own. To avoid breaking common
99 /// `npx ...` / `uvx ...` / `python -m mcp_server_*` setups (#1244), the
100 /// MCP-launch allowlist is wider than the base shell-tool allowlist: it
101 /// also passes through Node, npm, Python, Ruby, Java, proxy, and CA-bundle
102 /// bootstrap variables. It still drops arbitrary parent env so secret-bearing
103 /// vars (`AWS_*`, `*_API_KEY`, `GITHUB_TOKEN`, …) are not silently exported.
104 pub fn sanitized_mcp_env<I, K, V>(overrides: I) -> Vec<(OsString, OsString)>
105 where
106 I: IntoIterator<Item = (K, V)>,
107 K: AsRef<OsStr>,
108 V: AsRef<OsStr>,
109 {
110 let mut env = Vec::new();
111 for (key, value) in std::env::vars_os() {
112 if is_allowed_mcp_env_key(&key) {
113 upsert_env(&mut env, key, value);
114 }
115 }
116 for (key, value) in overrides {
117 upsert_env(
118 &mut env,
119 key.as_ref().to_os_string(),
120 value.as_ref().to_os_string(),
121 );
122 }
123 env
124 }
125
126 /// Build the environment for a reviewed plugin-contributed MCP child.
127 ///
128 /// Unlike user-authored MCP configuration, a plugin must name every extra
129 /// environment source during trust review. Start from the ordinary
130 /// secret-scrubbed child environment, remove ambient proxy variables whose
131 /// URLs may themselves contain credentials, then apply only reviewed
132 /// overrides. `NO_PROXY` remains safe routing metadata.
133 #[cfg(test)]
134 pub fn sanitized_plugin_mcp_env<I, K, V>(overrides: I) -> Vec<(OsString, OsString)>
135 where
136 I: IntoIterator<Item = (K, V)>,
137 K: AsRef<OsStr>,
138 V: AsRef<OsStr>,
139 {
140 sanitized_plugin_mcp_env_from(std::env::vars_os(), overrides)
141 }
142
143 /// Build a reviewed plugin child environment from an immutable host snapshot.
144 ///
145 /// This is separate from `sanitized_plugin_mcp_env` so a repository-local
146 /// dotenv file loaded after startup cannot add or replace inherited values.
147 pub fn sanitized_plugin_mcp_env_from<B, BK, BV, I, K, V>(
148 base_environment: B,
149 overrides: I,
150 ) -> Vec<(OsString, OsString)>
151 where
152 B: IntoIterator<Item = (BK, BV)>,
153 BK: AsRef<OsStr>,
154 BV: AsRef<OsStr>,
155 I: IntoIterator<Item = (K, V)>,
156 K: AsRef<OsStr>,
157 V: AsRef<OsStr>,
158 {
159 let mut env = Vec::new();
160 for (key, value) in base_environment {
161 if is_allowed_parent_env_key(key.as_ref()) {
162 upsert_env(
163 &mut env,
164 key.as_ref().to_os_string(),
165 value.as_ref().to_os_string(),
166 );
167 }
168 }
169 env.retain(|(key, _)| {
170 !matches!(
171 normalize_key(key).as_str(),
172 "HTTP_PROXY" | "HTTPS_PROXY" | "ALL_PROXY" | "FTP_PROXY"
173 )
174 });
175 for (key, value) in overrides {
176 upsert_env(
177 &mut env,
178 key.as_ref().to_os_string(),
179 value.as_ref().to_os_string(),
180 );
181 }
182 env
183 }
184
185 pub fn apply_to_tokio_command_mcp<I, K, V>(cmd: &mut tokio::process::Command, overrides: I)
186 where
187 I: IntoIterator<Item = (K, V)>,
188 K: AsRef<OsStr>,
189 V: AsRef<OsStr>,
190 {
191 cmd.env_clear();
192 for (key, value) in sanitized_mcp_env(overrides) {
193 cmd.env(key, value);
194 }
195 }
196
197 fn is_allowed_parent_env_key(key: &OsStr) -> bool {
198 let key = key.to_string_lossy();
199 let normalized = key.to_ascii_uppercase();
200 matches!(
201 normalized.as_str(),
202 "PATH"
203 | "HOME"
204 | "USER"
205 | "USERNAME"
206 | "LOGNAME"
207 | "LANG"
208 | "LANGUAGE"
209 | "LC_ALL"
210 | "LC_CTYPE"
211 | "LC_MESSAGES"
212 | "TERM"
213 | "COLORTERM"
214 | "NO_COLOR"
215 | "FORCE_COLOR"
216 | "SHELL"
217 | "TMPDIR"
218 | "TMP"
219 | "TEMP"
220 | "__CF_USER_TEXT_ENCODING"
221 | "SYSTEMROOT"
222 | "WINDIR"
223 | "COMSPEC"
224 | "PATHEXT"
225 | "USERPROFILE"
226 | "HOMEDRIVE"
227 | "HOMEPATH"
228 // Preserve Windows toolchain context when the parent shell has
229 // already loaded VsDevCmd / vcvars. Without these, `exec_shell`
230 // can find `link.exe` via PATH but still fail to resolve
231 // SDK/CRT libraries like `kernel32.lib`, so any model-driven
232 // `cargo build` from inside the TUI silently breaks on
233 // Windows installs that don't run inside a Developer Command
234 // Prompt. Harvested from PR #1487.
235 | "LIB"
236 | "LIBPATH"
237 | "INCLUDE"
238 | "VSINSTALLDIR"
239 | "VCINSTALLDIR"
240 | "VCTOOLSINSTALLDIR"
241 | "WINDOWSSDKDIR"
242 | "WINDOWSSDKVERSION"
243 | "UNIVERSALCRTSDKDIR"
244 | "UCRTVERSION"
245 | "EXTENSIONSDKDIR"
246 | "DEVENVDIR"
247 | "VISUALSTUDIOVERSION"
248 // Windows app-data + .NET/NuGet paths. `dotnet restore` (and npm,
249 // pip, etc.) resolve their package caches, HTTP cache, and config
250 // under %APPDATA% / %LOCALAPPDATA% / %ProgramData% / %ProgramFiles%.
251 // The sanitized child env dropped these, so restore failed through
252 // `exec_shell` even though it worked in the user's own shell, where
253 // the full environment is present (#1857). `DOTNET_*` (below) covers
254 // DOTNET_ROOT and the CLI flags.
255 | "APPDATA"
256 | "LOCALAPPDATA"
257 | "PROGRAMDATA"
258 | "ALLUSERSPROFILE"
259 | "PROGRAMFILES"
260 | "PROGRAMFILES(X86)"
261 | "PROGRAMW6432"
262 | "COMMONPROGRAMFILES"
263 | "COMMONPROGRAMFILES(X86)"
264 | "COMMONPROGRAMW6432"
265 | "PROCESSOR_ARCHITECTURE"
266 | "NUGET_PACKAGES"
267 | "NUGET_HTTP_CACHE_PATH"
268 // Standard proxy variables are needed by shell tasks in
269 // corporate and WSL environments where direct internet egress is
270 // blocked. They intentionally exclude token/API-key-shaped vars.
271 | "HTTP_PROXY"
272 | "HTTPS_PROXY"
273 | "NO_PROXY"
274 | "ALL_PROXY"
275 | "FTP_PROXY"
276 // Python uses these to pick stdio/default encodings when stdout is
277 // piped instead of attached to a Windows console (#4202).
278 | "PYTHONIOENCODING"
279 | "PYTHONUTF8"
280 // Rustup installs `cargo`/`rustc` as shims and resolves the real
281 // toolchain through these non-secret bootstrap paths. Dropping
282 // them makes an otherwise working Rust toolchain unusable in
283 // official Rust containers and other non-default installations.
284 | "CARGO_HOME"
285 | "RUSTUP_HOME"
286 | "RUSTUP_TOOLCHAIN"
287 ) || normalized.starts_with("LC_")
288 // .NET CLI / SDK configuration (DOTNET_ROOT, DOTNET_CLI_*,
289 // DOTNET_NOLOGO, DOTNET_CLI_TELEMETRY_OPTOUT, …). Paths and flags
290 // only — no secret-shaped values (#1857).
291 || normalized.starts_with("DOTNET_")
292 || is_allowed_platform_path_like_child_env_key(&normalized)
293 }
294
295 #[cfg(windows)]
296 fn is_allowed_platform_path_like_child_env_key(normalized: &str) -> bool {
297 is_allowed_path_like_child_env_key(normalized)
298 }
299
300 #[cfg(not(windows))]
301 fn is_allowed_platform_path_like_child_env_key(_normalized: &str) -> bool {
302 false
303 }
304
305 #[cfg(windows)]
306 fn is_allowed_path_like_child_env_key(normalized: &str) -> bool {
307 if is_secret_like_child_env_key(normalized) {
308 return false;
309 }
310 normalized.ends_with("_ROOT")
311 || normalized.ends_with("_DIR")
312 || normalized.ends_with("_HOME")
313 || normalized.ends_with("_PATH")
314 || normalized.ends_with("_PATHS")
315 || normalized.ends_with("SDKROOT")
316 }
317
318 #[cfg(windows)]
319 fn is_secret_like_child_env_key(normalized: &str) -> bool {
320 normalized.contains("SECRET")
321 || normalized.contains("TOKEN")
322 || normalized.contains("PASSWORD")
323 || normalized.contains("PASSWD")
324 || normalized.contains("CREDENTIAL")
325 || normalized.contains("API_KEY")
326 || normalized.contains("ACCESS_KEY")
327 || normalized.contains("PRIVATE_KEY")
328 || normalized.ends_with("_KEY")
329 }
330
331 /// Allowlist for MCP stdio launches. Strict superset of
332 /// `is_allowed_parent_env_key`. See `sanitized_mcp_env` for rationale.
333 fn is_allowed_mcp_env_key(key: &OsStr) -> bool {
334 if is_allowed_parent_env_key(key) {
335 return true;
336 }
337 let key_str = key.to_string_lossy();
338 let normalized = key_str.to_ascii_uppercase();
339 if matches!(
340 normalized.as_str(),
341 // Node.js / npm / npx / pnpm / yarn / volta / corepack
342 "NVM_DIR"
343 | "NVM_BIN"
344 | "NVM_INC"
345 | "VOLTA_HOME"
346 | "COREPACK_HOME"
347 | "NODE_PATH"
348 | "NODE_OPTIONS"
349 | "NODE_EXTRA_CA_CERTS"
350 // Python ecosystem
351 | "PYTHONPATH"
352 | "PYTHONHOME"
353 | "PYTHONDONTWRITEBYTECODE"
354 | "PYTHONUNBUFFERED"
355 | "VIRTUAL_ENV"
356 | "POETRY_HOME"
357 | "PIPX_HOME"
358 | "PIPX_BIN_DIR"
359 // Ruby ecosystem
360 | "GEM_HOME"
361 | "GEM_PATH"
362 | "BUNDLE_PATH"
363 | "BUNDLE_GEMFILE"
364 // Java
365 | "JAVA_HOME"
366 // Network proxies (uppercase form; lowercase handled below)
367 | "HTTP_PROXY"
368 | "HTTPS_PROXY"
369 | "NO_PROXY"
370 | "ALL_PROXY"
371 | "FTP_PROXY"
372 // Custom CA bundles for corporate TLS interception
373 | "SSL_CERT_FILE"
374 | "SSL_CERT_DIR"
375 | "REQUESTS_CA_BUNDLE"
376 | "CURL_CA_BUNDLE"
377 ) {
378 return true;
379 }
380 // npm config namespace (NPM_CONFIG_PREFIX, NPM_CONFIG_CACHE, …) and
381 // uv (UV_CACHE_DIR, UV_PYTHON, …) — both ecosystems use a stable prefix
382 // for their bootstrap configuration, so allow the whole namespace.
383 if normalized.starts_with("NPM_CONFIG_") || normalized.starts_with("UV_") {
384 return true;
385 }
386 false
387 }
388
389 #[cfg(windows)]
390 fn append_sanitized_child_env_candidates<I, K, V>(
391 env: &mut Vec<(OsString, OsString)>,
392 candidates: I,
393 ) where
394 I: IntoIterator<Item = (K, V)>,
395 K: Into<OsString>,
396 V: Into<OsString>,
397 {
398 for (key, value) in candidates {
399 append_sanitized_child_env_candidate(env, key.into(), value.into());
400 }
401 }
402
403 fn append_sanitized_child_env_candidate(
404 env: &mut Vec<(OsString, OsString)>,
405 key: OsString,
406 value: OsString,
407 ) {
408 if is_allowed_parent_env_key(&key) {
409 upsert_env(env, key, value);
410 }
411 }
412
413 fn upsert_env(env: &mut Vec<(OsString, OsString)>, key: OsString, value: OsString) {
414 let normalized = normalize_key(&key);
415 env.retain(|(existing, _)| normalize_key(existing) != normalized);
416 env.push((key, value));
417 }
418
419 #[cfg(windows)]
420 fn windows_registry_env_vars() -> Vec<(OsString, OsString)> {
421 let mut env = Vec::new();
422 append_windows_registry_env_key(
423 &mut env,
424 HKEY_LOCAL_MACHINE,
425 r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment",
426 );
427 append_windows_registry_env_key(&mut env, HKEY_CURRENT_USER, "Environment");
428 env
429 }
430
431 #[cfg(windows)]
432 fn append_windows_registry_env_key(env: &mut Vec<(OsString, OsString)>, root: HKEY, subkey: &str) {
433 let mut key = HKEY::default();
434 let subkey_wide = windows_wide_null(OsStr::new(subkey));
435 let open =
436 unsafe { RegOpenKeyExW(root, PCWSTR(subkey_wide.as_ptr()), None, KEY_READ, &mut key) };
437 if open != ERROR_SUCCESS {
438 return;
439 }
440
441 let mut index = 0;
442 loop {
443 match read_windows_registry_env_value(key, index) {
444 RegistryEnvValue::Value(name, value) => {
445 upsert_env(env, name, value);
446 index += 1;
447 }
448 RegistryEnvValue::Skip => {
449 index += 1;
450 }
451 RegistryEnvValue::Done => break,
452 }
453 }
454
455 let _ = unsafe { RegCloseKey(key) };
456 }
457
458 #[cfg(windows)]
459 enum RegistryEnvValue {
460 Value(OsString, OsString),
461 Skip,
462 Done,
463 }
464
465 #[cfg(windows)]
466 fn read_windows_registry_env_value(key: HKEY, index: u32) -> RegistryEnvValue {
467 let mut name = vec![0u16; 32_767];
468 let mut data = vec![0u8; 65_536];
469
470 loop {
471 let mut name_len = name.len() as u32;
472 let mut data_len = data.len() as u32;
473 let mut value_type = 0u32;
474 let status = unsafe {
475 RegEnumValueW(
476 key,
477 index,
478 Some(PWSTR(name.as_mut_ptr())),
479 &mut name_len,
480 None,
481 Some(&mut value_type),
482 Some(data.as_mut_ptr()),
483 Some(&mut data_len),
484 )
485 };
486
487 if status == ERROR_NO_MORE_ITEMS {
488 return RegistryEnvValue::Done;
489 }
490 if status == ERROR_MORE_DATA && resize_registry_data_buffer(&mut data, data_len) {
491 continue;
492 }
493 if status != ERROR_SUCCESS {
494 return RegistryEnvValue::Skip;
495 }
496 if value_type != REG_SZ.0 && value_type != REG_EXPAND_SZ.0 {
497 return RegistryEnvValue::Skip;
498 }
499
500 let name = OsString::from_wide(&name[..name_len as usize]);
501 let value = registry_utf16_value_from_bytes(&data[..data_len as usize]);
502 let value = if REG_VALUE_TYPE(value_type) == REG_EXPAND_SZ {
503 expand_windows_env_string(&value).unwrap_or(value)
504 } else {
505 value
506 };
507 return RegistryEnvValue::Value(name, value);
508 }
509 }
510
511 #[cfg(windows)]
512 fn resize_registry_data_buffer(data: &mut Vec<u8>, required_len: u32) -> bool {
513 let Ok(required_len) = usize::try_from(required_len) else {
514 return false;
515 };
516 if required_len <= data.len() {
517 return false;
518 }
519 data.resize(required_len, 0);
520 true
521 }
522
523 #[cfg(windows)]
524 fn registry_utf16_value_from_bytes(data: &[u8]) -> OsString {
525 let mut wide = data
526 .chunks_exact(2)
527 .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
528 .collect::<Vec<_>>();
529 while wide.last() == Some(&0) {
530 wide.pop();
531 }
532 OsString::from_wide(&wide)
533 }
534
535 #[cfg(windows)]
536 fn expand_windows_env_string(value: &OsStr) -> Option<OsString> {
537 let src = windows_wide_null(value);
538 let required_len = unsafe { ExpandEnvironmentStringsW(PCWSTR(src.as_ptr()), None) };
539 if required_len == 0 {
540 return None;
541 }
542
543 let mut expanded = vec![0u16; required_len as usize];
544 let written = unsafe { ExpandEnvironmentStringsW(PCWSTR(src.as_ptr()), Some(&mut expanded)) };
545 if written == 0 || written > required_len {
546 return None;
547 }
548
549 let len = usize::try_from(written).ok()?.saturating_sub(1);
550 Some(OsString::from_wide(&expanded[..len]))
551 }
552
553 #[cfg(windows)]
554 fn windows_wide_null(value: &OsStr) -> Vec<u16> {
555 value.encode_wide().chain(std::iter::once(0)).collect()
556 }
557
558 #[cfg(any(windows, test))]
559 fn fill_windows_common_program_files(env: &mut Vec<(OsString, OsString)>) {
560 for (key, default) in [
561 ("CommonProgramFiles", r"C:\Program Files\Common Files"),
562 (
563 "CommonProgramFiles(x86)",
564 r"C:\Program Files (x86)\Common Files",
565 ),
566 ("CommonProgramW6432", r"C:\Program Files\Common Files"),
567 ] {
568 let existing = env
569 .iter()
570 .find(|(existing, _)| normalize_key(existing) == normalize_key(OsStr::new(key)))
571 .map(|(_, value)| value.to_string_lossy().trim().is_empty());
572 if existing.unwrap_or(true) {
573 upsert_env(env, OsString::from(key), OsString::from(default));
574 }
575 }
576 }
577
578 fn normalize_key(key: &OsStr) -> String {
579 key.to_string_lossy().to_ascii_uppercase()
580 }
581
582 #[cfg(test)]
583 mod tests {
584 use super::*;
585 use crate::test_support::EnvVarGuard;
586
587 #[test]
588 fn mcp_env_allowlist_inherits_base_keys() {
589 for key in [
590 "PATH",
591 "HOME",
592 "USER",
593 "TERM",
594 "LANG",
595 "SHELL",
596 "LIB",
597 "LIBPATH",
598 "INCLUDE",
599 "VCTOOLSINSTALLDIR",
600 "WINDOWSSDKDIR",
601 ] {
602 assert!(
603 is_allowed_mcp_env_key(OsStr::new(key)),
604 "MCP allowlist should inherit base key {key}"
605 );
606 }
607 }
608
609 #[test]
610 fn mcp_env_allowlist_includes_node_bootstrap_keys() {
611 for key in [
612 "NVM_DIR",
613 "NVM_BIN",
614 "NVM_INC",
615 "NODE_PATH",
616 "NODE_OPTIONS",
617 "NODE_EXTRA_CA_CERTS",
618 "VOLTA_HOME",
619 "COREPACK_HOME",
620 ] {
621 assert!(
622 is_allowed_mcp_env_key(OsStr::new(key)),
623 "MCP allowlist should include {key}"
624 );
625 }
626 }
627
628 #[test]
629 fn mcp_env_allowlist_includes_npm_config_prefix() {
630 for key in [
631 "NPM_CONFIG_PREFIX",
632 "NPM_CONFIG_CACHE",
633 "NPM_CONFIG_REGISTRY",
634 "NPM_CONFIG_USERCONFIG",
635 ] {
636 assert!(
637 is_allowed_mcp_env_key(OsStr::new(key)),
638 "MCP allowlist should include npm config key {key}"
639 );
640 }
641 }
642
643 #[test]
644 fn mcp_env_allowlist_includes_proxy_keys_either_case() {
645 for key in [
646 "HTTP_PROXY",
647 "HTTPS_PROXY",
648 "NO_PROXY",
649 "ALL_PROXY",
650 "http_proxy",
651 "https_proxy",
652 "no_proxy",
653 "all_proxy",
654 ] {
655 assert!(
656 is_allowed_mcp_env_key(OsStr::new(key)),
657 "MCP allowlist should include proxy key {key}"
658 );
659 }
660 }
661
662 #[test]
663 fn child_env_allowlist_includes_proxy_keys_either_case() {
664 for key in [
665 "HTTP_PROXY",
666 "HTTPS_PROXY",
667 "NO_PROXY",
668 "ALL_PROXY",
669 "FTP_PROXY",
670 "http_proxy",
671 "https_proxy",
672 "no_proxy",
673 "all_proxy",
674 "ftp_proxy",
675 ] {
676 assert!(
677 is_allowed_parent_env_key(OsStr::new(key)),
678 "child env allowlist should include proxy key {key}"
679 );
680 }
681 }
682
683 #[test]
684 fn child_env_allowlist_includes_dotnet_and_windows_appdata_keys() {
685 // #1857: dotnet restore / NuGet need these to find caches and config.
686 for key in [
687 "APPDATA",
688 "LOCALAPPDATA",
689 "PROGRAMDATA",
690 "ALLUSERSPROFILE",
691 "PROGRAMFILES",
692 "PROGRAMFILES(X86)",
693 "PROGRAMW6432",
694 "COMMONPROGRAMFILES",
695 "COMMONPROGRAMFILES(X86)",
696 "COMMONPROGRAMW6432",
697 "PROCESSOR_ARCHITECTURE",
698 "NUGET_PACKAGES",
699 "DOTNET_ROOT",
700 "DOTNET_CLI_TELEMETRY_OPTOUT",
701 "DOTNET_NOLOGO",
702 // Case-insensitive: the real Windows var is `ProgramFiles`.
703 "ProgramFiles",
704 "dotnet_root",
705 ] {
706 assert!(
707 is_allowed_parent_env_key(OsStr::new(key)),
708 "child env allowlist should include {key}"
709 );
710 }
711 // Guard: NuGet credential env vars must still be dropped.
712 assert!(
713 !is_allowed_parent_env_key(OsStr::new("NuGetPackageSourceCredentials_feed")),
714 "NuGet credential vars must not be exported to child processes"
715 );
716 }
717
718 #[test]
719 fn child_env_allowlist_includes_python_stdio_encoding_vars() {
720 for key in ["PYTHONIOENCODING", "PYTHONUTF8", "pythonioencoding"] {
721 assert!(
722 is_allowed_parent_env_key(OsStr::new(key)),
723 "child env allowlist should include Python stdio encoding key {key}"
724 );
725 }
726 }
727
728 #[test]
729 fn child_env_allowlist_includes_rust_toolchain_bootstrap_keys() {
730 for key in [
731 "CARGO_HOME",
732 "RUSTUP_HOME",
733 "RUSTUP_TOOLCHAIN",
734 "cargo_home",
735 ] {
736 assert!(
737 is_allowed_parent_env_key(OsStr::new(key)),
738 "child env allowlist should include Rust bootstrap key {key}"
739 );
740 }
741 }
742
743 #[cfg(windows)]
744 #[test]
745 fn child_env_allowlist_includes_custom_path_like_vars_without_secrets() {
746 // #3572: SDK/toolchain roots created through Windows Environment
747 // Variables are often project-specific and cannot be exhaustively
748 // named in the static allowlist.
749 for key in [
750 "BIMRV_SDK_ROOT",
751 "ACME_TOOLCHAIN_HOME",
752 "PROJECT_SDK_DIR",
753 "CMAKE_PREFIX_PATH",
754 "ANDROID_SDKROOT",
755 ] {
756 assert!(
757 is_allowed_parent_env_key(OsStr::new(key)),
758 "child env allowlist should include path-like key {key}"
759 );
760 }
761
762 for key in [
763 "OPENAI_API_KEY",
764 "GITHUB_TOKEN",
765 "MY_SECRET_ROOT",
766 "SERVICE_PASSWORD_DIR",
767 "AWS_ACCESS_KEY_ID",
768 "PRIVATE_KEY_PATH",
769 "NuGetPackageSourceCredentials_feed",
770 ] {
771 assert!(
772 !is_allowed_parent_env_key(OsStr::new(key)),
773 "secret-like key {key} must not be exported to child processes"
774 );
775 }
776 }
777
778 #[cfg(windows)]
779 #[test]
780 fn sanitized_child_env_preserves_custom_sdk_root_vars() {
781 let _guard = crate::test_support::lock_test_env();
782 let _sdk = EnvVarGuard::set("BIMRV_SDK_ROOT", r"F:\Lib\BimRv27.5");
783 let _secret = EnvVarGuard::set("MY_SECRET_ROOT", r"F:\Secrets");
784
785 let env = sanitized_child_env(std::iter::empty::<(OsString, OsString)>());
786
787 assert!(
788 env.iter()
789 .any(|(key, value)| key == "BIMRV_SDK_ROOT" && value == r"F:\Lib\BimRv27.5"),
790 "child env should preserve custom SDK roots"
791 );
792 assert!(
793 env.iter().all(|(key, _)| key != "MY_SECRET_ROOT"),
794 "secret-like path vars must still be dropped"
795 );
796 }
797
798 #[cfg(windows)]
799 #[test]
800 fn windows_registry_env_candidates_preserve_custom_sdk_roots() {
801 use windows::Win32::System::Registry::{
802 HKEY_CURRENT_USER, REG_SZ, RegCreateKeyW, RegDeleteTreeW, RegSetValueExW,
803 };
804
805 let subkey = format!(r"Software\CodeWhaleTest\child_env_{}", std::process::id());
806 let subkey_wide = windows_wide_null(OsStr::new(&subkey));
807 let mut key = HKEY::default();
808 let created =
809 unsafe { RegCreateKeyW(HKEY_CURRENT_USER, PCWSTR(subkey_wide.as_ptr()), &mut key) };
810 assert_eq!(created, ERROR_SUCCESS);
811
812 set_registry_string_value(key, "BIMRV_SDK_ROOT", r"F:\Lib\BimRv27.5");
813 set_registry_string_value(key, "MY_SECRET_ROOT", r"F:\Secrets");
814 let _ = unsafe { RegCloseKey(key) };
815
816 let mut candidates = Vec::new();
817 append_windows_registry_env_key(&mut candidates, HKEY_CURRENT_USER, &subkey);
818 let mut env = Vec::new();
819 append_sanitized_child_env_candidates(&mut env, candidates);
820
821 let _ = unsafe { RegDeleteTreeW(HKEY_CURRENT_USER, PCWSTR(subkey_wide.as_ptr())) };
822
823 assert!(
824 env.iter()
825 .any(|(key, value)| key == "BIMRV_SDK_ROOT" && value == r"F:\Lib\BimRv27.5"),
826 "registry child env should preserve custom SDK roots"
827 );
828 assert!(
829 env.iter().all(|(key, _)| key != "MY_SECRET_ROOT"),
830 "secret-like registry vars must still be dropped"
831 );
832
833 fn set_registry_string_value(key: HKEY, name: &str, value: &str) {
834 let name_wide = windows_wide_null(OsStr::new(name));
835 let data = value
836 .encode_utf16()
837 .chain(std::iter::once(0))
838 .flat_map(u16::to_le_bytes)
839 .collect::<Vec<_>>();
840 let status = unsafe {
841 RegSetValueExW(key, PCWSTR(name_wide.as_ptr()), None, REG_SZ, Some(&data))
842 };
843 assert_eq!(status, ERROR_SUCCESS);
844 }
845 }
846
847 #[test]
848 fn windows_common_program_files_defaults_replace_empty_values() {
849 let mut env = vec![
850 (OsString::from("CommonProgramFiles"), OsString::new()),
851 (
852 OsString::from("CommonProgramFiles(x86)"),
853 OsString::from(" "),
854 ),
855 (
856 OsString::from("CommonProgramW6432"),
857 OsString::from(r"D:\Common Files"),
858 ),
859 ];
860
861 fill_windows_common_program_files(&mut env);
862
863 let get = |name: &str| {
864 env.iter()
865 .find(|(key, _)| normalize_key(key) == normalize_key(OsStr::new(name)))
866 .map(|(_, value)| value.to_string_lossy().into_owned())
867 };
868 assert_eq!(
869 get("CommonProgramFiles").as_deref(),
870 Some(r"C:\Program Files\Common Files")
871 );
872 assert_eq!(
873 get("CommonProgramFiles(x86)").as_deref(),
874 Some(r"C:\Program Files (x86)\Common Files")
875 );
876 assert_eq!(
877 get("CommonProgramW6432").as_deref(),
878 Some(r"D:\Common Files")
879 );
880 }
881
882 #[test]
883 fn mcp_env_allowlist_includes_python_bootstrap_keys() {
884 for key in [
885 "PYTHONPATH",
886 "PYTHONHOME",
887 "VIRTUAL_ENV",
888 "PIPX_HOME",
889 "PIPX_BIN_DIR",
890 "POETRY_HOME",
891 ] {
892 assert!(
893 is_allowed_mcp_env_key(OsStr::new(key)),
894 "MCP allowlist should include python bootstrap key {key}"
895 );
896 }
897 }
898
899 #[test]
900 fn mcp_env_allowlist_includes_uv_prefixed_keys() {
901 for key in ["UV_CACHE_DIR", "UV_INDEX_URL", "UV_PYTHON"] {
902 assert!(
903 is_allowed_mcp_env_key(OsStr::new(key)),
904 "MCP allowlist should include uv prefixed key {key}"
905 );
906 }
907 }
908
909 #[test]
910 fn mcp_env_allowlist_includes_ca_bundles() {
911 for key in [
912 "SSL_CERT_FILE",
913 "SSL_CERT_DIR",
914 "REQUESTS_CA_BUNDLE",
915 "CURL_CA_BUNDLE",
916 ] {
917 assert!(
918 is_allowed_mcp_env_key(OsStr::new(key)),
919 "MCP allowlist should include CA bundle key {key}"
920 );
921 }
922 }
923
924 #[test]
925 fn mcp_env_allowlist_excludes_secrets_and_creds() {
926 for key in [
927 "AWS_SECRET_ACCESS_KEY",
928 "AWS_ACCESS_KEY_ID",
929 "GITHUB_TOKEN",
930 "OPENAI_API_KEY",
931 "ANTHROPIC_API_KEY",
932 "DEEPSEEK_API_KEY",
933 "SLACK_TOKEN",
934 "MY_RANDOM_SECRET",
935 ] {
936 assert!(
937 !is_allowed_mcp_env_key(OsStr::new(key)),
938 "MCP allowlist must NOT include {key}"
939 );
940 }
941 }
942
943 #[test]
944 fn sanitized_mcp_env_passes_through_node_bootstrap() {
945 let _guard = crate::test_support::lock_test_env();
946 let _nvm_dir = EnvVarGuard::set("NVM_DIR", "/tmp/test-nvm");
947
948 let env = sanitized_mcp_env(std::iter::empty::<(OsString, OsString)>());
949
950 let nvm_dir = env
951 .iter()
952 .find(|(key, _)| normalize_key(key) == "NVM_DIR")
953 .map(|(_, value)| value.clone());
954 assert_eq!(nvm_dir, Some(OsString::from("/tmp/test-nvm")));
955 }
956
957 #[test]
958 fn sanitized_mcp_env_drops_unrelated_secret_like_values() {
959 let _guard = crate::test_support::lock_test_env();
960 let _secret = EnvVarGuard::set("DEEPSEEK_MCP_TEST_SECRET", "should-not-leak");
961
962 let env = sanitized_mcp_env(std::iter::empty::<(OsString, OsString)>());
963
964 assert!(
965 env.iter().all(|(key, _)| key != "DEEPSEEK_MCP_TEST_SECRET"),
966 "MCP env should not pass arbitrary parent vars"
967 );
968 }
969
970 #[test]
971 fn reviewed_plugin_mcp_env_requires_explicit_proxy_provenance() {
972 let _guard = crate::test_support::lock_test_env();
973 let synthetic_proxy = format!(
974 "{}://{}:{}@{}",
975 "http", "fixture-user", "fixture-password", "127.0.0.1:9"
976 );
977 let _proxy = EnvVarGuard::set("HTTP_PROXY", synthetic_proxy);
978
979 let ambient = sanitized_plugin_mcp_env(std::iter::empty::<(OsString, OsString)>());
980 let explicit = sanitized_plugin_mcp_env([("HTTP_PROXY", "http://proxy.invalid")]);
981
982 assert!(
983 ambient
984 .iter()
985 .all(|(key, _)| normalize_key(key) != "HTTP_PROXY"),
986 "reviewed plugins must not inherit a credential-capable proxy URL"
987 );
988 assert!(explicit.iter().any(|(key, value)| {
989 normalize_key(key) == "HTTP_PROXY" && value == "http://proxy.invalid"
990 }));
991 }
992
993 #[test]
994 fn sanitized_child_env_drops_parent_secret_like_values() {
995 let _guard = crate::test_support::lock_test_env();
996 let _secret = EnvVarGuard::set("DEEPSEEK_CHILD_ENV_TEST_SECRET", "parent-secret");
997
998 let env = sanitized_child_env(std::iter::empty::<(OsString, OsString)>());
999
1000 assert!(
1001 env.iter()
1002 .all(|(key, _)| key != "DEEPSEEK_CHILD_ENV_TEST_SECRET")
1003 );
1004 }
1005
1006 #[test]
1007 fn explicit_child_env_values_win_over_parent_allowlist() {
1008 let _guard = crate::test_support::lock_test_env();
1009 let _path = EnvVarGuard::set("PATH", "/parent/bin");
1010
1011 let env = sanitized_child_env([(OsString::from("PATH"), OsString::from("/explicit/bin"))]);
1012
1013 let path = env
1014 .iter()
1015 .find(|(key, _)| normalize_key(key) == "PATH")
1016 .map(|(_, value)| value);
1017 assert_eq!(path, Some(&OsString::from("/explicit/bin")));
1018 }
1019
1020 #[test]
1021 fn sanitized_child_env_preserves_windows_toolchain_vars() {
1022 let _guard = crate::test_support::lock_test_env();
1023 let _lib = EnvVarGuard::set("LIB", r"C:\sdk\lib");
1024 let _include = EnvVarGuard::set("INCLUDE", r"C:\sdk\include");
1025 let _sdk = EnvVarGuard::set("WINDOWSSDKDIR", r"C:\sdk");
1026
1027 let env = sanitized_child_env(std::iter::empty::<(OsString, OsString)>());
1028
1029 assert!(
1030 env.iter()
1031 .any(|(key, value)| key == "LIB" && value == r"C:\sdk\lib"),
1032 "child env should preserve LIB"
1033 );
1034 assert!(
1035 env.iter()
1036 .any(|(key, value)| key == "INCLUDE" && value == r"C:\sdk\include"),
1037 "child env should preserve INCLUDE"
1038 );
1039 assert!(
1040 env.iter()
1041 .any(|(key, value)| key == "WINDOWSSDKDIR" && value == r"C:\sdk"),
1042 "child env should preserve WINDOWSSDKDIR"
1043 );
1044 }
1045 }
1046
1046 lines RUST