返回 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 // Desktop connection metadata. Computer-use tools must reach
204 // the existing X11/Wayland and accessibility bus sessions. Keep
205 // this list exact; never inherit arbitrary XDG/DBUS namespaces
206 // or read the Xauthority credential file into the environment.
207 | "DISPLAY"
208 | "WAYLAND_DISPLAY"
209 | "XDG_RUNTIME_DIR"
210 | "XDG_SESSION_TYPE"
211 | "DBUS_SESSION_BUS_ADDRESS"
212 | "XAUTHORITY"
213 | "HOME"
214 | "USER"
215 | "USERNAME"
216 | "LOGNAME"
217 | "LANG"
218 | "LANGUAGE"
219 | "LC_ALL"
220 | "LC_CTYPE"
221 | "LC_MESSAGES"
222 | "TERM"
223 | "COLORTERM"
224 | "NO_COLOR"
225 | "FORCE_COLOR"
226 | "SHELL"
227 | "TMPDIR"
228 | "TMP"
229 | "TEMP"
230 | "__CF_USER_TEXT_ENCODING"
231 | "SYSTEMROOT"
232 | "WINDIR"
233 | "COMSPEC"
234 | "PATHEXT"
235 | "USERPROFILE"
236 | "HOMEDRIVE"
237 | "HOMEPATH"
238 // Preserve Windows toolchain context when the parent shell has
239 // already loaded VsDevCmd / vcvars. Without these, `exec_shell`
240 // can find `link.exe` via PATH but still fail to resolve
241 // SDK/CRT libraries like `kernel32.lib`, so any model-driven
242 // `cargo build` from inside the TUI silently breaks on
243 // Windows installs that don't run inside a Developer Command
244 // Prompt. Harvested from PR #1487.
245 | "LIB"
246 | "LIBPATH"
247 | "INCLUDE"
248 | "VSINSTALLDIR"
249 | "VCINSTALLDIR"
250 | "VCTOOLSINSTALLDIR"
251 | "WINDOWSSDKDIR"
252 | "WINDOWSSDKVERSION"
253 | "UNIVERSALCRTSDKDIR"
254 | "UCRTVERSION"
255 | "EXTENSIONSDKDIR"
256 | "DEVENVDIR"
257 | "VISUALSTUDIOVERSION"
258 // Windows app-data + .NET/NuGet paths. `dotnet restore` (and npm,
259 // pip, etc.) resolve their package caches, HTTP cache, and config
260 // under %APPDATA% / %LOCALAPPDATA% / %ProgramData% / %ProgramFiles%.
261 // The sanitized child env dropped these, so restore failed through
262 // `exec_shell` even though it worked in the user's own shell, where
263 // the full environment is present (#1857). `DOTNET_*` (below) covers
264 // DOTNET_ROOT and the CLI flags.
265 | "APPDATA"
266 | "LOCALAPPDATA"
267 | "PROGRAMDATA"
268 | "ALLUSERSPROFILE"
269 | "PROGRAMFILES"
270 | "PROGRAMFILES(X86)"
271 | "PROGRAMW6432"
272 | "COMMONPROGRAMFILES"
273 | "COMMONPROGRAMFILES(X86)"
274 | "COMMONPROGRAMW6432"
275 | "PROCESSOR_ARCHITECTURE"
276 | "NUGET_PACKAGES"
277 | "NUGET_HTTP_CACHE_PATH"
278 // Standard proxy variables are needed by shell tasks in
279 // corporate and WSL environments where direct internet egress is
280 // blocked. They intentionally exclude token/API-key-shaped vars.
281 | "HTTP_PROXY"
282 | "HTTPS_PROXY"
283 | "NO_PROXY"
284 | "ALL_PROXY"
285 | "FTP_PROXY"
286 // Python uses these to pick stdio/default encodings when stdout is
287 // piped instead of attached to a Windows console (#4202).
288 | "PYTHONIOENCODING"
289 | "PYTHONUTF8"
290 // Rustup installs `cargo`/`rustc` as shims and resolves the real
291 // toolchain through these non-secret bootstrap paths. Dropping
292 // them makes an otherwise working Rust toolchain unusable in
293 // official Rust containers and other non-default installations.
294 | "CARGO_HOME"
295 | "RUSTUP_HOME"
296 | "RUSTUP_TOOLCHAIN"
297 ) || normalized.starts_with("LC_")
298 // .NET CLI / SDK configuration (DOTNET_ROOT, DOTNET_CLI_*,
299 // DOTNET_NOLOGO, DOTNET_CLI_TELEMETRY_OPTOUT, …). Paths and flags
300 // only — no secret-shaped values (#1857).
301 || normalized.starts_with("DOTNET_")
302 || is_allowed_platform_path_like_child_env_key(&normalized)
303 }
304
305 #[cfg(windows)]
306 fn is_allowed_platform_path_like_child_env_key(normalized: &str) -> bool {
307 is_allowed_path_like_child_env_key(normalized)
308 }
309
310 #[cfg(not(windows))]
311 fn is_allowed_platform_path_like_child_env_key(_normalized: &str) -> bool {
312 false
313 }
314
315 #[cfg(windows)]
316 fn is_allowed_path_like_child_env_key(normalized: &str) -> bool {
317 if is_secret_like_child_env_key(normalized) {
318 return false;
319 }
320 normalized.ends_with("_ROOT")
321 || normalized.ends_with("_DIR")
322 || normalized.ends_with("_HOME")
323 || normalized.ends_with("_PATH")
324 || normalized.ends_with("_PATHS")
325 || normalized.ends_with("SDKROOT")
326 }
327
328 #[cfg(windows)]
329 fn is_secret_like_child_env_key(normalized: &str) -> bool {
330 normalized.contains("SECRET")
331 || normalized.contains("TOKEN")
332 || normalized.contains("PASSWORD")
333 || normalized.contains("PASSWD")
334 || normalized.contains("CREDENTIAL")
335 || normalized.contains("API_KEY")
336 || normalized.contains("ACCESS_KEY")
337 || normalized.contains("PRIVATE_KEY")
338 || normalized.ends_with("_KEY")
339 }
340
341 /// Allowlist for MCP stdio launches. Strict superset of
342 /// `is_allowed_parent_env_key`. See `sanitized_mcp_env` for rationale.
343 fn is_allowed_mcp_env_key(key: &OsStr) -> bool {
344 if is_allowed_parent_env_key(key) {
345 return true;
346 }
347 let key_str = key.to_string_lossy();
348 let normalized = key_str.to_ascii_uppercase();
349 if matches!(
350 normalized.as_str(),
351 // Node.js / npm / npx / pnpm / yarn / volta / corepack
352 "NVM_DIR"
353 | "NVM_BIN"
354 | "NVM_INC"
355 | "VOLTA_HOME"
356 | "COREPACK_HOME"
357 | "NODE_PATH"
358 | "NODE_OPTIONS"
359 | "NODE_EXTRA_CA_CERTS"
360 // Python ecosystem
361 | "PYTHONPATH"
362 | "PYTHONHOME"
363 | "PYTHONDONTWRITEBYTECODE"
364 | "PYTHONUNBUFFERED"
365 | "VIRTUAL_ENV"
366 | "POETRY_HOME"
367 | "PIPX_HOME"
368 | "PIPX_BIN_DIR"
369 // Ruby ecosystem
370 | "GEM_HOME"
371 | "GEM_PATH"
372 | "BUNDLE_PATH"
373 | "BUNDLE_GEMFILE"
374 // Java
375 | "JAVA_HOME"
376 // Network proxies (uppercase form; lowercase handled below)
377 | "HTTP_PROXY"
378 | "HTTPS_PROXY"
379 | "NO_PROXY"
380 | "ALL_PROXY"
381 | "FTP_PROXY"
382 // Custom CA bundles for corporate TLS interception
383 | "SSL_CERT_FILE"
384 | "SSL_CERT_DIR"
385 | "REQUESTS_CA_BUNDLE"
386 | "CURL_CA_BUNDLE"
387 ) {
388 return true;
389 }
390 // npm config namespace (NPM_CONFIG_PREFIX, NPM_CONFIG_CACHE, …) and
391 // uv (UV_CACHE_DIR, UV_PYTHON, …) — both ecosystems use a stable prefix
392 // for their bootstrap configuration, so allow the whole namespace.
393 if normalized.starts_with("NPM_CONFIG_") || normalized.starts_with("UV_") {
394 return true;
395 }
396 false
397 }
398
399 #[cfg(windows)]
400 fn append_sanitized_child_env_candidates<I, K, V>(
401 env: &mut Vec<(OsString, OsString)>,
402 candidates: I,
403 ) where
404 I: IntoIterator<Item = (K, V)>,
405 K: Into<OsString>,
406 V: Into<OsString>,
407 {
408 for (key, value) in candidates {
409 append_sanitized_child_env_candidate(env, key.into(), value.into());
410 }
411 }
412
413 fn append_sanitized_child_env_candidate(
414 env: &mut Vec<(OsString, OsString)>,
415 key: OsString,
416 value: OsString,
417 ) {
418 if is_allowed_parent_env_key(&key) {
419 upsert_env(env, key, value);
420 }
421 }
422
423 fn upsert_env(env: &mut Vec<(OsString, OsString)>, key: OsString, value: OsString) {
424 let normalized = normalize_key(&key);
425 env.retain(|(existing, _)| normalize_key(existing) != normalized);
426 env.push((key, value));
427 }
428
429 #[cfg(windows)]
430 fn windows_registry_env_vars() -> Vec<(OsString, OsString)> {
431 let mut env = Vec::new();
432 append_windows_registry_env_key(
433 &mut env,
434 HKEY_LOCAL_MACHINE,
435 r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment",
436 );
437 append_windows_registry_env_key(&mut env, HKEY_CURRENT_USER, "Environment");
438 env
439 }
440
441 #[cfg(windows)]
442 fn append_windows_registry_env_key(env: &mut Vec<(OsString, OsString)>, root: HKEY, subkey: &str) {
443 let mut key = HKEY::default();
444 let subkey_wide = windows_wide_null(OsStr::new(subkey));
445 // SAFETY: `subkey_wide` is NUL-terminated and live; `key` is live.
446 let open =
447 unsafe { RegOpenKeyExW(root, PCWSTR(subkey_wide.as_ptr()), None, KEY_READ, &mut key) };
448 if open != ERROR_SUCCESS {
449 return;
450 }
451
452 let mut index = 0;
453 loop {
454 match read_windows_registry_env_value(key, index) {
455 RegistryEnvValue::Value(name, value) => {
456 upsert_env(env, name, value);
457 index += 1;
458 }
459 RegistryEnvValue::Skip => {
460 index += 1;
461 }
462 RegistryEnvValue::Done => break,
463 }
464 }
465
466 // SAFETY: `key` was opened above and is not used after.
467 let _ = unsafe { RegCloseKey(key) };
468 }
469
470 #[cfg(windows)]
471 enum RegistryEnvValue {
472 Value(OsString, OsString),
473 Skip,
474 Done,
475 }
476
477 #[cfg(windows)]
478 fn read_windows_registry_env_value(key: HKEY, index: u32) -> RegistryEnvValue {
479 let mut name = vec![0u16; 32_767];
480 let mut data = vec![0u8; 65_536];
481
482 loop {
483 let mut name_len = name.len() as u32;
484 let mut data_len = data.len() as u32;
485 let mut value_type = 0u32;
486 // SAFETY: buffers are live with matching lengths passed.
487 let status = unsafe {
488 RegEnumValueW(
489 key,
490 index,
491 Some(PWSTR(name.as_mut_ptr())),
492 &mut name_len,
493 None,
494 Some(&mut value_type),
495 Some(data.as_mut_ptr()),
496 Some(&mut data_len),
497 )
498 };
499
500 if status == ERROR_NO_MORE_ITEMS {
501 return RegistryEnvValue::Done;
502 }
503 if status == ERROR_MORE_DATA && resize_registry_data_buffer(&mut data, data_len) {
504 continue;
505 }
506 if status != ERROR_SUCCESS {
507 return RegistryEnvValue::Skip;
508 }
509 if value_type != REG_SZ.0 && value_type != REG_EXPAND_SZ.0 {
510 return RegistryEnvValue::Skip;
511 }
512
513 let name = OsString::from_wide(&name[..name_len as usize]);
514 let value = registry_utf16_value_from_bytes(&data[..data_len as usize]);
515 let value = if REG_VALUE_TYPE(value_type) == REG_EXPAND_SZ {
516 expand_windows_env_string(&value).unwrap_or(value)
517 } else {
518 value
519 };
520 return RegistryEnvValue::Value(name, value);
521 }
522 }
523
524 #[cfg(windows)]
525 fn resize_registry_data_buffer(data: &mut Vec<u8>, required_len: u32) -> bool {
526 let Ok(required_len) = usize::try_from(required_len) else {
527 return false;
528 };
529 if required_len <= data.len() {
530 return false;
531 }
532 data.resize(required_len, 0);
533 true
534 }
535
536 #[cfg(windows)]
537 fn registry_utf16_value_from_bytes(data: &[u8]) -> OsString {
538 let mut wide = data
539 .chunks_exact(2)
540 .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
541 .collect::<Vec<_>>();
542 while wide.last() == Some(&0) {
543 wide.pop();
544 }
545 OsString::from_wide(&wide)
546 }
547
548 #[cfg(windows)]
549 fn expand_windows_env_string(value: &OsStr) -> Option<OsString> {
550 let src = windows_wide_null(value);
551 // SAFETY: `src` is NUL-terminated and live.
552 let required_len = unsafe { ExpandEnvironmentStringsW(PCWSTR(src.as_ptr()), None) };
553 if required_len == 0 {
554 return None;
555 }
556
557 let mut expanded = vec![0u16; required_len as usize];
558 // SAFETY: `src` is NUL-terminated; `expanded` has the queried length.
559 let written = unsafe { ExpandEnvironmentStringsW(PCWSTR(src.as_ptr()), Some(&mut expanded)) };
560 if written == 0 || written > required_len {
561 return None;
562 }
563
564 let len = usize::try_from(written).ok()?.saturating_sub(1);
565 Some(OsString::from_wide(&expanded[..len]))
566 }
567
568 #[cfg(windows)]
569 fn windows_wide_null(value: &OsStr) -> Vec<u16> {
570 value.encode_wide().chain(std::iter::once(0)).collect()
571 }
572
573 #[cfg(any(windows, test))]
574 fn fill_windows_common_program_files(env: &mut Vec<(OsString, OsString)>) {
575 for (key, default) in [
576 ("CommonProgramFiles", r"C:\Program Files\Common Files"),
577 (
578 "CommonProgramFiles(x86)",
579 r"C:\Program Files (x86)\Common Files",
580 ),
581 ("CommonProgramW6432", r"C:\Program Files\Common Files"),
582 ] {
583 let existing = env
584 .iter()
585 .find(|(existing, _)| normalize_key(existing) == normalize_key(OsStr::new(key)))
586 .map(|(_, value)| value.to_string_lossy().trim().is_empty());
587 if existing.unwrap_or(true) {
588 upsert_env(env, OsString::from(key), OsString::from(default));
589 }
590 }
591 }
592
593 fn normalize_key(key: &OsStr) -> String {
594 key.to_string_lossy().to_ascii_uppercase()
595 }
596
597 #[cfg(test)]
598 mod tests {
599 use super::*;
600 use crate::test_support::EnvVarGuard;
601
602 #[test]
603 fn mcp_env_allowlist_inherits_base_keys() {
604 for key in [
605 "PATH",
606 "HOME",
607 "USER",
608 "TERM",
609 "LANG",
610 "SHELL",
611 "LIB",
612 "LIBPATH",
613 "INCLUDE",
614 "VCTOOLSINSTALLDIR",
615 "WINDOWSSDKDIR",
616 ] {
617 assert!(
618 is_allowed_mcp_env_key(OsStr::new(key)),
619 "MCP allowlist should inherit base key {key}"
620 );
621 }
622 }
623
624 #[test]
625 fn mcp_env_allowlist_includes_node_bootstrap_keys() {
626 for key in [
627 "NVM_DIR",
628 "NVM_BIN",
629 "NVM_INC",
630 "NODE_PATH",
631 "NODE_OPTIONS",
632 "NODE_EXTRA_CA_CERTS",
633 "VOLTA_HOME",
634 "COREPACK_HOME",
635 ] {
636 assert!(
637 is_allowed_mcp_env_key(OsStr::new(key)),
638 "MCP allowlist should include {key}"
639 );
640 }
641 }
642
643 #[test]
644 fn mcp_env_allowlist_includes_npm_config_prefix() {
645 for key in [
646 "NPM_CONFIG_PREFIX",
647 "NPM_CONFIG_CACHE",
648 "NPM_CONFIG_REGISTRY",
649 "NPM_CONFIG_USERCONFIG",
650 ] {
651 assert!(
652 is_allowed_mcp_env_key(OsStr::new(key)),
653 "MCP allowlist should include npm config key {key}"
654 );
655 }
656 }
657
658 #[test]
659 fn mcp_env_allowlist_includes_proxy_keys_either_case() {
660 for key in [
661 "HTTP_PROXY",
662 "HTTPS_PROXY",
663 "NO_PROXY",
664 "ALL_PROXY",
665 "http_proxy",
666 "https_proxy",
667 "no_proxy",
668 "all_proxy",
669 ] {
670 assert!(
671 is_allowed_mcp_env_key(OsStr::new(key)),
672 "MCP allowlist should include proxy key {key}"
673 );
674 }
675 }
676
677 #[test]
678 fn child_env_allowlist_includes_proxy_keys_either_case() {
679 for key in [
680 "HTTP_PROXY",
681 "HTTPS_PROXY",
682 "NO_PROXY",
683 "ALL_PROXY",
684 "FTP_PROXY",
685 "http_proxy",
686 "https_proxy",
687 "no_proxy",
688 "all_proxy",
689 "ftp_proxy",
690 ] {
691 assert!(
692 is_allowed_parent_env_key(OsStr::new(key)),
693 "child env allowlist should include proxy key {key}"
694 );
695 }
696 }
697
698 #[test]
699 fn child_env_allowlist_includes_dotnet_and_windows_appdata_keys() {
700 // #1857: dotnet restore / NuGet need these to find caches and config.
701 for key in [
702 "APPDATA",
703 "LOCALAPPDATA",
704 "PROGRAMDATA",
705 "ALLUSERSPROFILE",
706 "PROGRAMFILES",
707 "PROGRAMFILES(X86)",
708 "PROGRAMW6432",
709 "COMMONPROGRAMFILES",
710 "COMMONPROGRAMFILES(X86)",
711 "COMMONPROGRAMW6432",
712 "PROCESSOR_ARCHITECTURE",
713 "NUGET_PACKAGES",
714 "DOTNET_ROOT",
715 "DOTNET_CLI_TELEMETRY_OPTOUT",
716 "DOTNET_NOLOGO",
717 // Case-insensitive: the real Windows var is `ProgramFiles`.
718 "ProgramFiles",
719 "dotnet_root",
720 ] {
721 assert!(
722 is_allowed_parent_env_key(OsStr::new(key)),
723 "child env allowlist should include {key}"
724 );
725 }
726 // Guard: NuGet credential env vars must still be dropped.
727 assert!(
728 !is_allowed_parent_env_key(OsStr::new("NuGetPackageSourceCredentials_feed")),
729 "NuGet credential vars must not be exported to child processes"
730 );
731 }
732
733 #[test]
734 fn child_env_allowlist_includes_python_stdio_encoding_vars() {
735 for key in ["PYTHONIOENCODING", "PYTHONUTF8", "pythonioencoding"] {
736 assert!(
737 is_allowed_parent_env_key(OsStr::new(key)),
738 "child env allowlist should include Python stdio encoding key {key}"
739 );
740 }
741 }
742
743 #[test]
744 fn child_env_allowlist_includes_rust_toolchain_bootstrap_keys() {
745 for key in [
746 "CARGO_HOME",
747 "RUSTUP_HOME",
748 "RUSTUP_TOOLCHAIN",
749 "cargo_home",
750 ] {
751 assert!(
752 is_allowed_parent_env_key(OsStr::new(key)),
753 "child env allowlist should include Rust bootstrap key {key}"
754 );
755 }
756 }
757
758 #[cfg(windows)]
759 #[test]
760 fn child_env_allowlist_includes_custom_path_like_vars_without_secrets() {
761 // #3572: SDK/toolchain roots created through Windows Environment
762 // Variables are often project-specific and cannot be exhaustively
763 // named in the static allowlist.
764 for key in [
765 "BIMRV_SDK_ROOT",
766 "ACME_TOOLCHAIN_HOME",
767 "PROJECT_SDK_DIR",
768 "CMAKE_PREFIX_PATH",
769 "ANDROID_SDKROOT",
770 ] {
771 assert!(
772 is_allowed_parent_env_key(OsStr::new(key)),
773 "child env allowlist should include path-like key {key}"
774 );
775 }
776
777 for key in [
778 "OPENAI_API_KEY",
779 "GITHUB_TOKEN",
780 "MY_SECRET_ROOT",
781 "SERVICE_PASSWORD_DIR",
782 "AWS_ACCESS_KEY_ID",
783 "PRIVATE_KEY_PATH",
784 "NuGetPackageSourceCredentials_feed",
785 ] {
786 assert!(
787 !is_allowed_parent_env_key(OsStr::new(key)),
788 "secret-like key {key} must not be exported to child processes"
789 );
790 }
791 }
792
793 #[cfg(windows)]
794 #[test]
795 fn sanitized_child_env_preserves_custom_sdk_root_vars() {
796 let _guard = crate::test_support::lock_test_env();
797 let _sdk = EnvVarGuard::set("BIMRV_SDK_ROOT", r"F:\Lib\BimRv27.5");
798 let _secret = EnvVarGuard::set("MY_SECRET_ROOT", r"F:\Secrets");
799
800 let env = sanitized_child_env(std::iter::empty::<(OsString, OsString)>());
801
802 assert!(
803 env.iter()
804 .any(|(key, value)| key == "BIMRV_SDK_ROOT" && value == r"F:\Lib\BimRv27.5"),
805 "child env should preserve custom SDK roots"
806 );
807 assert!(
808 env.iter().all(|(key, _)| key != "MY_SECRET_ROOT"),
809 "secret-like path vars must still be dropped"
810 );
811 }
812
813 #[cfg(windows)]
814 #[test]
815 fn windows_registry_env_candidates_preserve_custom_sdk_roots() {
816 use windows::Win32::System::Registry::{
817 HKEY_CURRENT_USER, REG_SZ, RegCreateKeyW, RegDeleteTreeW, RegSetValueExW,
818 };
819
820 let subkey = format!(r"Software\CodeWhaleTest\child_env_{}", std::process::id());
821 let subkey_wide = windows_wide_null(OsStr::new(&subkey));
822 let mut key = HKEY::default();
823 let created =
824 unsafe { RegCreateKeyW(HKEY_CURRENT_USER, PCWSTR(subkey_wide.as_ptr()), &mut key) };
825 assert_eq!(created, ERROR_SUCCESS);
826
827 set_registry_string_value(key, "BIMRV_SDK_ROOT", r"F:\Lib\BimRv27.5");
828 set_registry_string_value(key, "MY_SECRET_ROOT", r"F:\Secrets");
829 let _ = unsafe { RegCloseKey(key) };
830
831 let mut candidates = Vec::new();
832 append_windows_registry_env_key(&mut candidates, HKEY_CURRENT_USER, &subkey);
833 let mut env = Vec::new();
834 append_sanitized_child_env_candidates(&mut env, candidates);
835
836 let _ = unsafe { RegDeleteTreeW(HKEY_CURRENT_USER, PCWSTR(subkey_wide.as_ptr())) };
837
838 assert!(
839 env.iter()
840 .any(|(key, value)| key == "BIMRV_SDK_ROOT" && value == r"F:\Lib\BimRv27.5"),
841 "registry child env should preserve custom SDK roots"
842 );
843 assert!(
844 env.iter().all(|(key, _)| key != "MY_SECRET_ROOT"),
845 "secret-like registry vars must still be dropped"
846 );
847
848 fn set_registry_string_value(key: HKEY, name: &str, value: &str) {
849 let name_wide = windows_wide_null(OsStr::new(name));
850 let data = value
851 .encode_utf16()
852 .chain(std::iter::once(0))
853 .flat_map(u16::to_le_bytes)
854 .collect::<Vec<_>>();
855 let status = unsafe {
856 RegSetValueExW(key, PCWSTR(name_wide.as_ptr()), None, REG_SZ, Some(&data))
857 };
858 assert_eq!(status, ERROR_SUCCESS);
859 }
860 }
861
862 #[test]
863 fn windows_common_program_files_defaults_replace_empty_values() {
864 let mut env = vec![
865 (OsString::from("CommonProgramFiles"), OsString::new()),
866 (
867 OsString::from("CommonProgramFiles(x86)"),
868 OsString::from(" "),
869 ),
870 (
871 OsString::from("CommonProgramW6432"),
872 OsString::from(r"D:\Common Files"),
873 ),
874 ];
875
876 fill_windows_common_program_files(&mut env);
877
878 let get = |name: &str| {
879 env.iter()
880 .find(|(key, _)| normalize_key(key) == normalize_key(OsStr::new(name)))
881 .map(|(_, value)| value.to_string_lossy().into_owned())
882 };
883 assert_eq!(
884 get("CommonProgramFiles").as_deref(),
885 Some(r"C:\Program Files\Common Files")
886 );
887 assert_eq!(
888 get("CommonProgramFiles(x86)").as_deref(),
889 Some(r"C:\Program Files (x86)\Common Files")
890 );
891 assert_eq!(
892 get("CommonProgramW6432").as_deref(),
893 Some(r"D:\Common Files")
894 );
895 }
896
897 #[test]
898 fn mcp_env_allowlist_includes_python_bootstrap_keys() {
899 for key in [
900 "PYTHONPATH",
901 "PYTHONHOME",
902 "VIRTUAL_ENV",
903 "PIPX_HOME",
904 "PIPX_BIN_DIR",
905 "POETRY_HOME",
906 ] {
907 assert!(
908 is_allowed_mcp_env_key(OsStr::new(key)),
909 "MCP allowlist should include python bootstrap key {key}"
910 );
911 }
912 }
913
914 #[test]
915 fn mcp_env_allowlist_includes_uv_prefixed_keys() {
916 for key in ["UV_CACHE_DIR", "UV_INDEX_URL", "UV_PYTHON"] {
917 assert!(
918 is_allowed_mcp_env_key(OsStr::new(key)),
919 "MCP allowlist should include uv prefixed key {key}"
920 );
921 }
922 }
923
924 #[test]
925 fn mcp_env_allowlist_includes_ca_bundles() {
926 for key in [
927 "SSL_CERT_FILE",
928 "SSL_CERT_DIR",
929 "REQUESTS_CA_BUNDLE",
930 "CURL_CA_BUNDLE",
931 ] {
932 assert!(
933 is_allowed_mcp_env_key(OsStr::new(key)),
934 "MCP allowlist should include CA bundle key {key}"
935 );
936 }
937 }
938
939 #[test]
940 fn mcp_env_allowlist_excludes_secrets_and_creds() {
941 for key in [
942 "AWS_SECRET_ACCESS_KEY",
943 "AWS_ACCESS_KEY_ID",
944 "GITHUB_TOKEN",
945 "OPENAI_API_KEY",
946 "ANTHROPIC_API_KEY",
947 "DEEPSEEK_API_KEY",
948 "SLACK_TOKEN",
949 "MY_RANDOM_SECRET",
950 ] {
951 assert!(
952 !is_allowed_mcp_env_key(OsStr::new(key)),
953 "MCP allowlist must NOT include {key}"
954 );
955 }
956 }
957
958 #[test]
959 fn sanitized_mcp_env_passes_through_node_bootstrap() {
960 let _guard = crate::test_support::lock_test_env();
961 let _nvm_dir = EnvVarGuard::set("NVM_DIR", "/tmp/test-nvm");
962
963 let env = sanitized_mcp_env(std::iter::empty::<(OsString, OsString)>());
964
965 let nvm_dir = env
966 .iter()
967 .find(|(key, _)| normalize_key(key) == "NVM_DIR")
968 .map(|(_, value)| value.clone());
969 assert_eq!(nvm_dir, Some(OsString::from("/tmp/test-nvm")));
970 }
971
972 #[test]
973 fn sanitized_mcp_env_drops_unrelated_secret_like_values() {
974 let _guard = crate::test_support::lock_test_env();
975 let _secret = EnvVarGuard::set("DEEPSEEK_MCP_TEST_SECRET", "should-not-leak");
976
977 let env = sanitized_mcp_env(std::iter::empty::<(OsString, OsString)>());
978
979 assert!(
980 env.iter().all(|(key, _)| key != "DEEPSEEK_MCP_TEST_SECRET"),
981 "MCP env should not pass arbitrary parent vars"
982 );
983 }
984
985 #[test]
986 fn reviewed_plugin_mcp_env_requires_explicit_proxy_provenance() {
987 let _guard = crate::test_support::lock_test_env();
988 let synthetic_proxy = format!(
989 "{}://{}:{}@{}",
990 "http", "fixture-user", "fixture-password", "127.0.0.1:9"
991 );
992 let _proxy = EnvVarGuard::set("HTTP_PROXY", synthetic_proxy);
993
994 let ambient = sanitized_plugin_mcp_env(std::iter::empty::<(OsString, OsString)>());
995 let explicit = sanitized_plugin_mcp_env([("HTTP_PROXY", "http://proxy.invalid")]);
996
997 assert!(
998 ambient
999 .iter()
1000 .all(|(key, _)| normalize_key(key) != "HTTP_PROXY"),
1001 "reviewed plugins must not inherit a credential-capable proxy URL"
1002 );
1003 assert!(explicit.iter().any(|(key, value)| {
1004 normalize_key(key) == "HTTP_PROXY" && value == "http://proxy.invalid"
1005 }));
1006 }
1007
1008 #[test]
1009 fn reviewed_plugin_mcp_env_keeps_desktop_routing_without_secret_namespaces() {
1010 let desktop = [
1011 ("DISPLAY", ":1"),
1012 ("WAYLAND_DISPLAY", "wayland-0"),
1013 ("XDG_RUNTIME_DIR", "/run/user/1000"),
1014 ("XDG_SESSION_TYPE", "wayland"),
1015 ("DBUS_SESSION_BUS_ADDRESS", "unix:path=/run/user/1000/bus"),
1016 ("XAUTHORITY", "/run/user/1000/.Xauthority"),
1017 ];
1018 let unexpected = [
1019 ("OPENAI_API_KEY", "provider-fixture"),
1020 ("XDG_PRIVATE_TOKEN", "xdg-fixture"),
1021 ("DBUS_PRIVATE_TOKEN", "dbus-fixture"),
1022 ("CODEWHALE_CU_APP_BUNDLE", "/stale/helper.app"),
1023 ];
1024 let child = sanitized_plugin_mcp_env_from(
1025 desktop.into_iter().chain(unexpected),
1026 std::iter::empty::<(&str, &str)>(),
1027 );
1028 for (key, value) in desktop {
1029 assert!(
1030 child
1031 .iter()
1032 .any(|(found, content)| found == key && content == value)
1033 );
1034 }
1035 for (key, _) in unexpected {
1036 assert!(child.iter().all(|(found, _)| found != key));
1037 }
1038 }
1039
1040 #[test]
1041 fn sanitized_child_env_drops_parent_secret_like_values() {
1042 let _guard = crate::test_support::lock_test_env();
1043 let _secret = EnvVarGuard::set("DEEPSEEK_CHILD_ENV_TEST_SECRET", "parent-secret");
1044
1045 let env = sanitized_child_env(std::iter::empty::<(OsString, OsString)>());
1046
1047 assert!(
1048 env.iter()
1049 .all(|(key, _)| key != "DEEPSEEK_CHILD_ENV_TEST_SECRET")
1050 );
1051 }
1052
1053 #[test]
1054 fn explicit_child_env_values_win_over_parent_allowlist() {
1055 let _guard = crate::test_support::lock_test_env();
1056 let _path = EnvVarGuard::set("PATH", "/parent/bin");
1057
1058 let env = sanitized_child_env([(OsString::from("PATH"), OsString::from("/explicit/bin"))]);
1059
1060 let path = env
1061 .iter()
1062 .find(|(key, _)| normalize_key(key) == "PATH")
1063 .map(|(_, value)| value);
1064 assert_eq!(path, Some(&OsString::from("/explicit/bin")));
1065 }
1066
1067 #[test]
1068 fn sanitized_child_env_preserves_windows_toolchain_vars() {
1069 let _guard = crate::test_support::lock_test_env();
1070 let _lib = EnvVarGuard::set("LIB", r"C:\sdk\lib");
1071 let _include = EnvVarGuard::set("INCLUDE", r"C:\sdk\include");
1072 let _sdk = EnvVarGuard::set("WINDOWSSDKDIR", r"C:\sdk");
1073
1074 let env = sanitized_child_env(std::iter::empty::<(OsString, OsString)>());
1075
1076 assert!(
1077 env.iter()
1078 .any(|(key, value)| key == "LIB" && value == r"C:\sdk\lib"),
1079 "child env should preserve LIB"
1080 );
1081 assert!(
1082 env.iter()
1083 .any(|(key, value)| key == "INCLUDE" && value == r"C:\sdk\include"),
1084 "child env should preserve INCLUDE"
1085 );
1086 assert!(
1087 env.iter()
1088 .any(|(key, value)| key == "WINDOWSSDKDIR" && value == r"C:\sdk"),
1089 "child env should preserve WINDOWSSDKDIR"
1090 );
1091 }
1092 }
1093
1093 lines RUST