返回 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 // OS sandboxes set CODEWHALE_SANDBOX (seatbelt / bwrap / windows). sccache
55 // and other RUSTC_WRAPPER compilers need sockets or files those sandboxes
56 // deny, so nested `cargo` fails with "Operation not permitted" before any
57 // crate compiles (seen on macOS CI seatbelt). Drop the wrapper so rustc
58 // still runs under the sandbox; unsandboxed children keep it.
59 if env
60 .iter()
61 .any(|(key, _)| normalize_key(key) == "CODEWHALE_SANDBOX")
62 {
63 env.retain(|(key, _)| normalize_key(key) != "RUSTC_WRAPPER");
64 }
65 env
66 }
67
68 /// Environment for a Codewhale runtime child (a Fleet worker), built from a
69 /// snapshot of the parent environment.
70 ///
71 /// It uses the same allowlist as [`sanitized_child_env`], so provider keys and
72 /// other secret-shaped variables are dropped, with one deliberate difference:
73 /// proxy URLs keep their `user:password@` part. The runtime child is Codewhale
74 /// itself, not a model-chosen program, and must reach its provider through the
75 /// same authenticated proxy as the parent. Every tool it starts builds its own
76 /// environment through [`sanitized_child_env`], which strips that userinfo at
77 /// the model-facing boundary.
78 pub fn sanitized_runtime_env_from<B, K, V>(base_environment: B) -> Vec<(OsString, OsString)>
79 where
80 B: IntoIterator<Item = (K, V)>,
81 K: AsRef<OsStr>,
82 V: AsRef<OsStr>,
83 {
84 let mut env = Vec::new();
85 for (key, value) in base_environment {
86 if is_allowed_parent_env_key(key.as_ref()) {
87 upsert_env(
88 &mut env,
89 key.as_ref().to_os_string(),
90 value.as_ref().to_os_string(),
91 );
92 }
93 }
94 env
95 }
96
97 pub fn apply_to_command<I, K, V>(cmd: &mut std::process::Command, overrides: I)
98 where
99 I: IntoIterator<Item = (K, V)>,
100 K: AsRef<OsStr>,
101 V: AsRef<OsStr>,
102 {
103 cmd.env_clear();
104 for (key, value) in sanitized_child_env(overrides) {
105 cmd.env(key, value);
106 }
107 }
108
109 pub fn apply_to_tokio_command<I, K, V>(cmd: &mut tokio::process::Command, overrides: I)
110 where
111 I: IntoIterator<Item = (K, V)>,
112 K: AsRef<OsStr>,
113 V: AsRef<OsStr>,
114 {
115 cmd.env_clear();
116 for (key, value) in sanitized_child_env(overrides) {
117 cmd.env(key, value);
118 }
119 }
120
121 /// Parent variables git needs beyond the base allowlist: the ssh agent for
122 /// remote transports, where the user's global config lives, a pinned ssh
123 /// transport, and author/committer identity. None of them is a secret value.
124 const GIT_PASSTHROUGH_KEYS: &[&str] = &[
125 "SSH_AUTH_SOCK",
126 "SSH_AGENT_PID",
127 "GNUPGHOME",
128 "XDG_CONFIG_HOME",
129 "GIT_SSH",
130 "GIT_SSH_COMMAND",
131 "GIT_SSH_VARIANT",
132 "GIT_EXEC_PATH",
133 "GIT_CONFIG_GLOBAL",
134 "GIT_CONFIG_SYSTEM",
135 "GIT_CONFIG_NOSYSTEM",
136 "GIT_CEILING_DIRECTORIES",
137 "GIT_SSL_CAINFO",
138 "GIT_SSL_CAPATH",
139 "GIT_AUTHOR_NAME",
140 "GIT_AUTHOR_EMAIL",
141 "GIT_COMMITTER_NAME",
142 "GIT_COMMITTER_EMAIL",
143 ];
144
145 /// Start a git child from the sanitized environment plus
146 /// [`GIT_PASSTHROUGH_KEYS`]. Repository config (`core.fsmonitor`, hooks,
147 /// filters, `core.sshCommand`) can make any git command run a program chosen
148 /// by whoever wrote the workspace, so git must not hand that program the
149 /// parent's credentials.
150 pub fn apply_to_git_command(cmd: &mut std::process::Command) {
151 let passthrough: Vec<(OsString, OsString)> = std::env::vars_os()
152 .filter(|(key, _)| {
153 let normalized = normalize_key(key);
154 GIT_PASSTHROUGH_KEYS.contains(&normalized.as_str())
155 })
156 .collect();
157 apply_to_command(cmd, passthrough);
158 }
159
160 #[cfg(not(target_env = "ohos"))]
161 pub fn apply_to_pty_command<I, K, V>(cmd: &mut portable_pty::CommandBuilder, overrides: I)
162 where
163 I: IntoIterator<Item = (K, V)>,
164 K: AsRef<OsStr>,
165 V: AsRef<OsStr>,
166 {
167 cmd.env_clear();
168 for (key, value) in sanitized_child_env(overrides) {
169 cmd.env(key, value);
170 }
171 }
172
173 /// Build the sanitized child environment used for MCP stdio servers.
174 ///
175 /// MCP stdio servers are user-configured integrations declared in
176 /// `~/.deepseek/mcp.json` (or equivalent). They are not arbitrary processes
177 /// the agent decided to launch on its own. To avoid breaking common
178 /// `npx ...` / `uvx ...` / `python -m mcp_server_*` setups (#1244), the
179 /// MCP-launch allowlist is wider than the base shell-tool allowlist: it
180 /// also passes through Node, npm, Python, Ruby, Java, proxy, and CA-bundle
181 /// bootstrap variables, plus the non-secret AWS profile/region/config-path
182 /// selectors. It still drops arbitrary parent env so secret-bearing vars
183 /// (AWS keys and session tokens, `*_API_KEY`, `GITHUB_TOKEN`, …) are not
184 /// silently exported.
185 pub fn sanitized_mcp_env<I, K, V>(overrides: I) -> Vec<(OsString, OsString)>
186 where
187 I: IntoIterator<Item = (K, V)>,
188 K: AsRef<OsStr>,
189 V: AsRef<OsStr>,
190 {
191 let mut env = Vec::new();
192 for (key, value) in std::env::vars_os() {
193 if is_allowed_mcp_env_key(&key) {
194 upsert_env(&mut env, key, value);
195 }
196 }
197 for (key, value) in overrides {
198 upsert_env(
199 &mut env,
200 key.as_ref().to_os_string(),
201 value.as_ref().to_os_string(),
202 );
203 }
204 env
205 }
206
207 /// Build the environment for a reviewed plugin-contributed MCP child.
208 ///
209 /// Unlike user-authored MCP configuration, a plugin must name every extra
210 /// environment source during trust review. Start from the ordinary
211 /// secret-scrubbed child environment, remove ambient proxy variables whose
212 /// URLs may themselves contain credentials, then apply only reviewed
213 /// overrides. `NO_PROXY` remains safe routing metadata.
214 #[cfg(test)]
215 pub fn sanitized_plugin_mcp_env<I, K, V>(overrides: I) -> Vec<(OsString, OsString)>
216 where
217 I: IntoIterator<Item = (K, V)>,
218 K: AsRef<OsStr>,
219 V: AsRef<OsStr>,
220 {
221 sanitized_plugin_mcp_env_from(std::env::vars_os(), overrides)
222 }
223
224 /// Build a reviewed plugin child environment from an immutable host snapshot.
225 ///
226 /// This is separate from `sanitized_plugin_mcp_env` so a repository-local
227 /// dotenv file loaded after startup cannot add or replace inherited values.
228 pub fn sanitized_plugin_mcp_env_from<B, BK, BV, I, K, V>(
229 base_environment: B,
230 overrides: I,
231 ) -> Vec<(OsString, OsString)>
232 where
233 B: IntoIterator<Item = (BK, BV)>,
234 BK: AsRef<OsStr>,
235 BV: AsRef<OsStr>,
236 I: IntoIterator<Item = (K, V)>,
237 K: AsRef<OsStr>,
238 V: AsRef<OsStr>,
239 {
240 let mut env = Vec::new();
241 for (key, value) in base_environment {
242 if is_allowed_parent_env_key(key.as_ref()) {
243 upsert_env(
244 &mut env,
245 key.as_ref().to_os_string(),
246 value.as_ref().to_os_string(),
247 );
248 }
249 }
250 env.retain(|(key, _)| {
251 !matches!(
252 normalize_key(key).as_str(),
253 "HTTP_PROXY" | "HTTPS_PROXY" | "ALL_PROXY" | "FTP_PROXY"
254 )
255 });
256 for (key, value) in overrides {
257 upsert_env(
258 &mut env,
259 key.as_ref().to_os_string(),
260 value.as_ref().to_os_string(),
261 );
262 }
263 env
264 }
265
266 pub fn apply_to_tokio_command_mcp<I, K, V>(cmd: &mut tokio::process::Command, overrides: I)
267 where
268 I: IntoIterator<Item = (K, V)>,
269 K: AsRef<OsStr>,
270 V: AsRef<OsStr>,
271 {
272 cmd.env_clear();
273 for (key, value) in sanitized_mcp_env(overrides) {
274 cmd.env(key, value);
275 }
276 }
277
278 fn is_allowed_parent_env_key(key: &OsStr) -> bool {
279 let key = key.to_string_lossy();
280 let normalized = key.to_ascii_uppercase();
281 matches!(
282 normalized.as_str(),
283 "PATH"
284 // Desktop connection metadata. Computer-use tools must reach
285 // the existing X11/Wayland and accessibility bus sessions. Keep
286 // this list exact; never inherit arbitrary XDG/DBUS namespaces
287 // or read the Xauthority credential file into the environment.
288 | "DISPLAY"
289 | "WAYLAND_DISPLAY"
290 | "XDG_RUNTIME_DIR"
291 | "XDG_SESSION_TYPE"
292 | "DBUS_SESSION_BUS_ADDRESS"
293 | "XAUTHORITY"
294 | "HOME"
295 | "USER"
296 | "USERNAME"
297 | "LOGNAME"
298 | "LANG"
299 | "LANGUAGE"
300 | "LC_ALL"
301 | "LC_CTYPE"
302 | "LC_MESSAGES"
303 | "TERM"
304 | "COLORTERM"
305 | "NO_COLOR"
306 | "FORCE_COLOR"
307 | "SHELL"
308 | "TMPDIR"
309 | "TMP"
310 | "TEMP"
311 | "__CF_USER_TEXT_ENCODING"
312 | "SYSTEMROOT"
313 | "WINDIR"
314 | "COMSPEC"
315 | "PATHEXT"
316 | "USERPROFILE"
317 | "HOMEDRIVE"
318 | "HOMEPATH"
319 // Preserve Windows toolchain context when the parent shell has
320 // already loaded VsDevCmd / vcvars. Without these, `exec_shell`
321 // can find `link.exe` via PATH but still fail to resolve
322 // SDK/CRT libraries like `kernel32.lib`, so any model-driven
323 // `cargo build` from inside the TUI silently breaks on
324 // Windows installs that don't run inside a Developer Command
325 // Prompt. Harvested from PR #1487.
326 | "LIB"
327 | "LIBPATH"
328 | "INCLUDE"
329 | "VSINSTALLDIR"
330 | "VCINSTALLDIR"
331 | "VCTOOLSINSTALLDIR"
332 | "WINDOWSSDKDIR"
333 | "WINDOWSSDKVERSION"
334 | "UNIVERSALCRTSDKDIR"
335 | "UCRTVERSION"
336 | "EXTENSIONSDKDIR"
337 | "DEVENVDIR"
338 | "VISUALSTUDIOVERSION"
339 // Windows app-data + .NET/NuGet paths. `dotnet restore` (and npm,
340 // pip, etc.) resolve their package caches, HTTP cache, and config
341 // under %APPDATA% / %LOCALAPPDATA% / %ProgramData% / %ProgramFiles%.
342 // The sanitized child env dropped these, so restore failed through
343 // `exec_shell` even though it worked in the user's own shell, where
344 // the full environment is present (#1857). `DOTNET_*` (below) covers
345 // DOTNET_ROOT and the CLI flags.
346 | "APPDATA"
347 | "LOCALAPPDATA"
348 | "PROGRAMDATA"
349 | "ALLUSERSPROFILE"
350 | "PROGRAMFILES"
351 | "PROGRAMFILES(X86)"
352 | "PROGRAMW6432"
353 | "COMMONPROGRAMFILES"
354 | "COMMONPROGRAMFILES(X86)"
355 | "COMMONPROGRAMW6432"
356 | "PROCESSOR_ARCHITECTURE"
357 | "NUGET_PACKAGES"
358 | "NUGET_HTTP_CACHE_PATH"
359 // Standard proxy variables are needed by shell tasks in
360 // corporate and WSL environments where direct internet egress is
361 // blocked. They intentionally exclude token/API-key-shaped vars.
362 | "HTTP_PROXY"
363 | "HTTPS_PROXY"
364 | "NO_PROXY"
365 | "ALL_PROXY"
366 | "FTP_PROXY"
367 // Python uses these to pick stdio/default encodings when stdout is
368 // piped instead of attached to a Windows console (#4202).
369 | "PYTHONIOENCODING"
370 | "PYTHONUTF8"
371 // Rustup installs `cargo`/`rustc` as shims and resolves the real
372 // toolchain through these non-secret bootstrap paths. Dropping
373 // them makes an otherwise working Rust toolchain unusable in
374 // official Rust containers and other non-default installations.
375 | "CARGO_HOME"
376 | "RUSTUP_HOME"
377 | "RUSTUP_TOOLCHAIN"
378 // Non-secret build and toolchain configuration. Model-run
379 // builds and tests (`run_tests`, verifier and task gates,
380 // language servers) must honour the same target dir, job limit,
381 // flags, active virtualenv and CA bundle as the user's own shell;
382 // dropping them caused full rebuilds, lost memory limits and TLS
383 // failures. `CARGO_*` is handled below without its secret-shaped
384 // and registry keys. Connection strings such as `DATABASE_URL`
385 // stay out: they usually embed a password. `RUSTC_WRAPPER` is
386 // kept for unsandboxed children only; see the CODEWHALE_SANDBOX
387 // scrub in `sanitized_child_env`.
388 | "RUSTFLAGS"
389 | "RUSTDOCFLAGS"
390 | "RUSTC_WRAPPER"
391 | "RUST_BACKTRACE"
392 | "RUST_LOG"
393 | "RUST_MIN_STACK"
394 | "RUST_TEST_THREADS"
395 | "VIRTUAL_ENV"
396 | "CONDA_PREFIX"
397 | "CONDA_DEFAULT_ENV"
398 | "PYTHONPATH"
399 | "JAVA_HOME"
400 | "GOPATH"
401 | "GOROOT"
402 | "GOBIN"
403 | "GOFLAGS"
404 | "GOCACHE"
405 | "GOMODCACHE"
406 | "GOTOOLCHAIN"
407 | "GO111MODULE"
408 | "NVM_DIR"
409 | "NVM_BIN"
410 | "NVM_INC"
411 | "VOLTA_HOME"
412 | "NODE_PATH"
413 | "NODE_OPTIONS"
414 | "NODE_EXTRA_CA_CERTS"
415 | "SSL_CERT_FILE"
416 | "SSL_CERT_DIR"
417 | "REQUESTS_CA_BUNDLE"
418 | "CURL_CA_BUNDLE"
419 ) || normalized.starts_with("LC_")
420 || is_allowed_cargo_config_key(&normalized)
421 // .NET CLI / SDK configuration (DOTNET_ROOT, DOTNET_CLI_*,
422 // DOTNET_NOLOGO, DOTNET_CLI_TELEMETRY_OPTOUT, …). Paths and flags
423 // only — no secret-shaped values (#1857).
424 || normalized.starts_with("DOTNET_")
425 || is_allowed_platform_path_like_child_env_key(&normalized)
426 }
427
428 /// Cargo's `CARGO_*` configuration namespace (`CARGO_TARGET_DIR`,
429 /// `CARGO_BUILD_JOBS`, `CARGO_INCREMENTAL`, ...) minus anything that can carry
430 /// a credential: registry tokens and credential providers live under
431 /// `CARGO_REGISTRY_` / `CARGO_REGISTRIES_`, and secret-shaped names are
432 /// dropped wherever they appear. `CARGO_HTTP_PROXY` is passed with its
433 /// userinfo removed, like the other proxy variables.
434 fn is_allowed_cargo_config_key(normalized: &str) -> bool {
435 normalized.starts_with("CARGO_")
436 && !normalized.starts_with("CARGO_REGISTRY_")
437 && !normalized.starts_with("CARGO_REGISTRIES_")
438 && !is_secret_like_child_env_key(normalized)
439 }
440
441 /// Proxy variables whose URL may embed `user:password@`.
442 fn is_proxy_url_key(normalized: &str) -> bool {
443 matches!(
444 normalized,
445 "HTTP_PROXY" | "HTTPS_PROXY" | "ALL_PROXY" | "FTP_PROXY" | "CARGO_HTTP_PROXY"
446 )
447 }
448
449 /// Remove the `user:password@` part of a proxy URL so a child keeps the route
450 /// (`scheme://host:port`) but not the proxy credentials. Values that are not
451 /// valid UTF-8 cannot be inspected and are dropped.
452 fn strip_proxy_userinfo(value: &OsStr) -> Option<OsString> {
453 let value = value.to_str()?;
454 let (scheme, rest) = match value.find("://") {
455 Some(index) => value.split_at(index + 3),
456 None => ("", value),
457 };
458 let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
459 let (authority, tail) = rest.split_at(authority_end);
460 let host = authority
461 .rsplit_once('@')
462 .map_or(authority, |(_, host)| host);
463 Some(OsString::from(format!("{scheme}{host}{tail}")))
464 }
465
466 #[cfg(windows)]
467 fn is_allowed_platform_path_like_child_env_key(normalized: &str) -> bool {
468 is_allowed_path_like_child_env_key(normalized)
469 }
470
471 #[cfg(not(windows))]
472 fn is_allowed_platform_path_like_child_env_key(_normalized: &str) -> bool {
473 false
474 }
475
476 #[cfg(windows)]
477 fn is_allowed_path_like_child_env_key(normalized: &str) -> bool {
478 if is_secret_like_child_env_key(normalized) {
479 return false;
480 }
481 normalized.ends_with("_ROOT")
482 || normalized.ends_with("_DIR")
483 || normalized.ends_with("_HOME")
484 || normalized.ends_with("_PATH")
485 || normalized.ends_with("_PATHS")
486 || normalized.ends_with("SDKROOT")
487 }
488
489 fn is_secret_like_child_env_key(normalized: &str) -> bool {
490 normalized.contains("SECRET")
491 || normalized.contains("TOKEN")
492 || normalized.contains("PASSWORD")
493 || normalized.contains("PASSWD")
494 || normalized.contains("CREDENTIAL")
495 || normalized.contains("API_KEY")
496 || normalized.contains("ACCESS_KEY")
497 || normalized.contains("PRIVATE_KEY")
498 || normalized.ends_with("_KEY")
499 }
500
501 /// Allowlist for MCP stdio launches. Strict superset of
502 /// `is_allowed_parent_env_key`. See `sanitized_mcp_env` for rationale.
503 fn is_allowed_mcp_env_key(key: &OsStr) -> bool {
504 if is_allowed_parent_env_key(key) {
505 return true;
506 }
507 let key_str = key.to_string_lossy();
508 let normalized = key_str.to_ascii_uppercase();
509 if matches!(
510 normalized.as_str(),
511 // Node.js / npm / npx / pnpm / yarn / volta / corepack
512 "NVM_DIR"
513 | "NVM_BIN"
514 | "NVM_INC"
515 | "VOLTA_HOME"
516 | "COREPACK_HOME"
517 | "NODE_PATH"
518 | "NODE_OPTIONS"
519 | "NODE_EXTRA_CA_CERTS"
520 // Python ecosystem
521 | "PYTHONPATH"
522 | "PYTHONHOME"
523 | "PYTHONDONTWRITEBYTECODE"
524 | "PYTHONUNBUFFERED"
525 | "VIRTUAL_ENV"
526 | "POETRY_HOME"
527 | "PIPX_HOME"
528 | "PIPX_BIN_DIR"
529 // Ruby ecosystem
530 | "GEM_HOME"
531 | "GEM_PATH"
532 | "BUNDLE_PATH"
533 | "BUNDLE_GEMFILE"
534 // Java
535 | "JAVA_HOME"
536 // Network proxies (uppercase form; lowercase handled below)
537 | "HTTP_PROXY"
538 | "HTTPS_PROXY"
539 | "NO_PROXY"
540 | "ALL_PROXY"
541 | "FTP_PROXY"
542 // Custom CA bundles for corporate TLS interception
543 | "SSL_CERT_FILE"
544 | "SSL_CERT_DIR"
545 | "REQUESTS_CA_BUNDLE"
546 | "CURL_CA_BUNDLE"
547 // AWS profile/region selection and config-file locations. These
548 // name *which* credentials to use, never the credentials
549 // themselves: an AWS MCP server launched with `--profile x`
550 // still needs the user's config file and region to resolve an
551 // SSO session. Keys, secrets and session tokens
552 // (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`,
553 // `AWS_SESSION_TOKEN`, …) stay dropped.
554 | "AWS_PROFILE"
555 | "AWS_REGION"
556 | "AWS_DEFAULT_REGION"
557 | "AWS_CONFIG_FILE"
558 | "AWS_SHARED_CREDENTIALS_FILE"
559 ) {
560 return true;
561 }
562 // npm config namespace (NPM_CONFIG_PREFIX, NPM_CONFIG_CACHE, …) and
563 // uv (UV_CACHE_DIR, UV_PYTHON, …) — both ecosystems use a stable prefix
564 // for their bootstrap configuration, so allow the whole namespace.
565 if normalized.starts_with("NPM_CONFIG_") || normalized.starts_with("UV_") {
566 return true;
567 }
568 false
569 }
570
571 #[cfg(windows)]
572 fn append_sanitized_child_env_candidates<I, K, V>(
573 env: &mut Vec<(OsString, OsString)>,
574 candidates: I,
575 ) where
576 I: IntoIterator<Item = (K, V)>,
577 K: Into<OsString>,
578 V: Into<OsString>,
579 {
580 for (key, value) in candidates {
581 append_sanitized_child_env_candidate(env, key.into(), value.into());
582 }
583 }
584
585 fn append_sanitized_child_env_candidate(
586 env: &mut Vec<(OsString, OsString)>,
587 key: OsString,
588 value: OsString,
589 ) {
590 if !is_allowed_parent_env_key(&key) {
591 return;
592 }
593 if is_proxy_url_key(&normalize_key(&key)) {
594 if let Some(value) = strip_proxy_userinfo(&value) {
595 upsert_env(env, key, value);
596 }
597 return;
598 }
599 upsert_env(env, key, value);
600 }
601
602 fn upsert_env(env: &mut Vec<(OsString, OsString)>, key: OsString, value: OsString) {
603 let normalized = normalize_key(&key);
604 env.retain(|(existing, _)| normalize_key(existing) != normalized);
605 env.push((key, value));
606 }
607
608 #[cfg(windows)]
609 fn windows_registry_env_vars() -> Vec<(OsString, OsString)> {
610 let mut env = Vec::new();
611 append_windows_registry_env_key(
612 &mut env,
613 HKEY_LOCAL_MACHINE,
614 r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment",
615 );
616 append_windows_registry_env_key(&mut env, HKEY_CURRENT_USER, "Environment");
617 env
618 }
619
620 #[cfg(windows)]
621 fn append_windows_registry_env_key(env: &mut Vec<(OsString, OsString)>, root: HKEY, subkey: &str) {
622 let mut key = HKEY::default();
623 let subkey_wide = windows_wide_null(OsStr::new(subkey));
624 // SAFETY: `subkey_wide` is NUL-terminated and live; `key` is live.
625 let open =
626 unsafe { RegOpenKeyExW(root, PCWSTR(subkey_wide.as_ptr()), None, KEY_READ, &mut key) };
627 if open != ERROR_SUCCESS {
628 return;
629 }
630
631 let mut index = 0;
632 loop {
633 match read_windows_registry_env_value(key, index) {
634 RegistryEnvValue::Value(name, value) => {
635 upsert_env(env, name, value);
636 index += 1;
637 }
638 RegistryEnvValue::Skip => {
639 index += 1;
640 }
641 RegistryEnvValue::Done => break,
642 }
643 }
644
645 // SAFETY: `key` was opened above and is not used after.
646 let _ = unsafe { RegCloseKey(key) };
647 }
648
649 #[cfg(windows)]
650 enum RegistryEnvValue {
651 Value(OsString, OsString),
652 Skip,
653 Done,
654 }
655
656 #[cfg(windows)]
657 fn read_windows_registry_env_value(key: HKEY, index: u32) -> RegistryEnvValue {
658 let mut name = vec![0u16; 32_767];
659 let mut data = vec![0u8; 65_536];
660
661 loop {
662 let mut name_len = name.len() as u32;
663 let mut data_len = data.len() as u32;
664 let mut value_type = 0u32;
665 // SAFETY: buffers are live with matching lengths passed.
666 let status = unsafe {
667 RegEnumValueW(
668 key,
669 index,
670 Some(PWSTR(name.as_mut_ptr())),
671 &mut name_len,
672 None,
673 Some(&mut value_type),
674 Some(data.as_mut_ptr()),
675 Some(&mut data_len),
676 )
677 };
678
679 if status == ERROR_NO_MORE_ITEMS {
680 return RegistryEnvValue::Done;
681 }
682 if status == ERROR_MORE_DATA && resize_registry_data_buffer(&mut data, data_len) {
683 continue;
684 }
685 if status != ERROR_SUCCESS {
686 return RegistryEnvValue::Skip;
687 }
688 if value_type != REG_SZ.0 && value_type != REG_EXPAND_SZ.0 {
689 return RegistryEnvValue::Skip;
690 }
691
692 let name = OsString::from_wide(&name[..name_len as usize]);
693 let value = registry_utf16_value_from_bytes(&data[..data_len as usize]);
694 let value = if REG_VALUE_TYPE(value_type) == REG_EXPAND_SZ {
695 expand_windows_env_string(&value).unwrap_or(value)
696 } else {
697 value
698 };
699 return RegistryEnvValue::Value(name, value);
700 }
701 }
702
703 #[cfg(windows)]
704 fn resize_registry_data_buffer(data: &mut Vec<u8>, required_len: u32) -> bool {
705 let Ok(required_len) = usize::try_from(required_len) else {
706 return false;
707 };
708 if required_len <= data.len() {
709 return false;
710 }
711 data.resize(required_len, 0);
712 true
713 }
714
715 #[cfg(windows)]
716 fn registry_utf16_value_from_bytes(data: &[u8]) -> OsString {
717 let mut wide = data
718 .chunks_exact(2)
719 .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
720 .collect::<Vec<_>>();
721 while wide.last() == Some(&0) {
722 wide.pop();
723 }
724 OsString::from_wide(&wide)
725 }
726
727 #[cfg(windows)]
728 fn expand_windows_env_string(value: &OsStr) -> Option<OsString> {
729 let src = windows_wide_null(value);
730 // SAFETY: `src` is NUL-terminated and live.
731 let required_len = unsafe { ExpandEnvironmentStringsW(PCWSTR(src.as_ptr()), None) };
732 if required_len == 0 {
733 return None;
734 }
735
736 let mut expanded = vec![0u16; required_len as usize];
737 // SAFETY: `src` is NUL-terminated; `expanded` has the queried length.
738 let written = unsafe { ExpandEnvironmentStringsW(PCWSTR(src.as_ptr()), Some(&mut expanded)) };
739 if written == 0 || written > required_len {
740 return None;
741 }
742
743 let len = usize::try_from(written).ok()?.saturating_sub(1);
744 Some(OsString::from_wide(&expanded[..len]))
745 }
746
747 #[cfg(windows)]
748 fn windows_wide_null(value: &OsStr) -> Vec<u16> {
749 value.encode_wide().chain(std::iter::once(0)).collect()
750 }
751
752 #[cfg(any(windows, test))]
753 fn fill_windows_common_program_files(env: &mut Vec<(OsString, OsString)>) {
754 for (key, default) in [
755 ("CommonProgramFiles", r"C:\Program Files\Common Files"),
756 (
757 "CommonProgramFiles(x86)",
758 r"C:\Program Files (x86)\Common Files",
759 ),
760 ("CommonProgramW6432", r"C:\Program Files\Common Files"),
761 ] {
762 let existing = env
763 .iter()
764 .find(|(existing, _)| normalize_key(existing) == normalize_key(OsStr::new(key)))
765 .map(|(_, value)| value.to_string_lossy().trim().is_empty());
766 if existing.unwrap_or(true) {
767 upsert_env(env, OsString::from(key), OsString::from(default));
768 }
769 }
770 }
771
772 fn normalize_key(key: &OsStr) -> String {
773 key.to_string_lossy().to_ascii_uppercase()
774 }
775
776 #[cfg(test)]
777 mod tests {
778 use super::*;
779 use crate::test_support::EnvVarGuard;
780
781 #[test]
782 fn mcp_env_allowlist_inherits_base_keys() {
783 for key in [
784 "PATH",
785 "HOME",
786 "USER",
787 "TERM",
788 "LANG",
789 "SHELL",
790 "LIB",
791 "LIBPATH",
792 "INCLUDE",
793 "VCTOOLSINSTALLDIR",
794 "WINDOWSSDKDIR",
795 ] {
796 assert!(
797 is_allowed_mcp_env_key(OsStr::new(key)),
798 "MCP allowlist should inherit base key {key}"
799 );
800 }
801 }
802
803 #[test]
804 fn mcp_env_allowlist_includes_node_bootstrap_keys() {
805 for key in [
806 "NVM_DIR",
807 "NVM_BIN",
808 "NVM_INC",
809 "NODE_PATH",
810 "NODE_OPTIONS",
811 "NODE_EXTRA_CA_CERTS",
812 "VOLTA_HOME",
813 "COREPACK_HOME",
814 ] {
815 assert!(
816 is_allowed_mcp_env_key(OsStr::new(key)),
817 "MCP allowlist should include {key}"
818 );
819 }
820 }
821
822 #[test]
823 fn mcp_env_allowlist_includes_npm_config_prefix() {
824 for key in [
825 "NPM_CONFIG_PREFIX",
826 "NPM_CONFIG_CACHE",
827 "NPM_CONFIG_REGISTRY",
828 "NPM_CONFIG_USERCONFIG",
829 ] {
830 assert!(
831 is_allowed_mcp_env_key(OsStr::new(key)),
832 "MCP allowlist should include npm config key {key}"
833 );
834 }
835 }
836
837 #[test]
838 fn mcp_env_allowlist_includes_proxy_keys_either_case() {
839 for key in [
840 "HTTP_PROXY",
841 "HTTPS_PROXY",
842 "NO_PROXY",
843 "ALL_PROXY",
844 "http_proxy",
845 "https_proxy",
846 "no_proxy",
847 "all_proxy",
848 ] {
849 assert!(
850 is_allowed_mcp_env_key(OsStr::new(key)),
851 "MCP allowlist should include proxy key {key}"
852 );
853 }
854 }
855
856 #[test]
857 fn child_env_allowlist_includes_proxy_keys_either_case() {
858 for key in [
859 "HTTP_PROXY",
860 "HTTPS_PROXY",
861 "NO_PROXY",
862 "ALL_PROXY",
863 "FTP_PROXY",
864 "http_proxy",
865 "https_proxy",
866 "no_proxy",
867 "all_proxy",
868 "ftp_proxy",
869 ] {
870 assert!(
871 is_allowed_parent_env_key(OsStr::new(key)),
872 "child env allowlist should include proxy key {key}"
873 );
874 }
875 }
876
877 #[test]
878 fn child_env_allowlist_includes_dotnet_and_windows_appdata_keys() {
879 // #1857: dotnet restore / NuGet need these to find caches and config.
880 for key in [
881 "APPDATA",
882 "LOCALAPPDATA",
883 "PROGRAMDATA",
884 "ALLUSERSPROFILE",
885 "PROGRAMFILES",
886 "PROGRAMFILES(X86)",
887 "PROGRAMW6432",
888 "COMMONPROGRAMFILES",
889 "COMMONPROGRAMFILES(X86)",
890 "COMMONPROGRAMW6432",
891 "PROCESSOR_ARCHITECTURE",
892 "NUGET_PACKAGES",
893 "DOTNET_ROOT",
894 "DOTNET_CLI_TELEMETRY_OPTOUT",
895 "DOTNET_NOLOGO",
896 // Case-insensitive: the real Windows var is `ProgramFiles`.
897 "ProgramFiles",
898 "dotnet_root",
899 ] {
900 assert!(
901 is_allowed_parent_env_key(OsStr::new(key)),
902 "child env allowlist should include {key}"
903 );
904 }
905 // Guard: NuGet credential env vars must still be dropped.
906 assert!(
907 !is_allowed_parent_env_key(OsStr::new("NuGetPackageSourceCredentials_feed")),
908 "NuGet credential vars must not be exported to child processes"
909 );
910 }
911
912 #[test]
913 fn child_env_allowlist_includes_python_stdio_encoding_vars() {
914 for key in ["PYTHONIOENCODING", "PYTHONUTF8", "pythonioencoding"] {
915 assert!(
916 is_allowed_parent_env_key(OsStr::new(key)),
917 "child env allowlist should include Python stdio encoding key {key}"
918 );
919 }
920 }
921
922 #[test]
923 fn child_env_allowlist_includes_rust_toolchain_bootstrap_keys() {
924 for key in [
925 "CARGO_HOME",
926 "RUSTUP_HOME",
927 "RUSTUP_TOOLCHAIN",
928 "cargo_home",
929 ] {
930 assert!(
931 is_allowed_parent_env_key(OsStr::new(key)),
932 "child env allowlist should include Rust bootstrap key {key}"
933 );
934 }
935 }
936
937 #[cfg(windows)]
938 #[test]
939 fn child_env_allowlist_includes_custom_path_like_vars_without_secrets() {
940 // #3572: SDK/toolchain roots created through Windows Environment
941 // Variables are often project-specific and cannot be exhaustively
942 // named in the static allowlist.
943 for key in [
944 "BIMRV_SDK_ROOT",
945 "ACME_TOOLCHAIN_HOME",
946 "PROJECT_SDK_DIR",
947 "CMAKE_PREFIX_PATH",
948 "ANDROID_SDKROOT",
949 ] {
950 assert!(
951 is_allowed_parent_env_key(OsStr::new(key)),
952 "child env allowlist should include path-like key {key}"
953 );
954 }
955
956 for key in [
957 "OPENAI_API_KEY",
958 "GITHUB_TOKEN",
959 "MY_SECRET_ROOT",
960 "SERVICE_PASSWORD_DIR",
961 "AWS_ACCESS_KEY_ID",
962 "PRIVATE_KEY_PATH",
963 "NuGetPackageSourceCredentials_feed",
964 ] {
965 assert!(
966 !is_allowed_parent_env_key(OsStr::new(key)),
967 "secret-like key {key} must not be exported to child processes"
968 );
969 }
970 }
971
972 #[cfg(windows)]
973 #[test]
974 fn sanitized_child_env_preserves_custom_sdk_root_vars() {
975 let _guard = crate::test_support::lock_test_env();
976 let _sdk = EnvVarGuard::set("BIMRV_SDK_ROOT", r"F:\Lib\BimRv27.5");
977 let _secret = EnvVarGuard::set("MY_SECRET_ROOT", r"F:\Secrets");
978
979 let env = sanitized_child_env(std::iter::empty::<(OsString, OsString)>());
980
981 assert!(
982 env.iter()
983 .any(|(key, value)| key == "BIMRV_SDK_ROOT" && value == r"F:\Lib\BimRv27.5"),
984 "child env should preserve custom SDK roots"
985 );
986 assert!(
987 env.iter().all(|(key, _)| key != "MY_SECRET_ROOT"),
988 "secret-like path vars must still be dropped"
989 );
990 }
991
992 #[cfg(windows)]
993 #[test]
994 fn windows_registry_env_candidates_preserve_custom_sdk_roots() {
995 use windows::Win32::System::Registry::{
996 HKEY_CURRENT_USER, REG_SZ, RegCreateKeyW, RegDeleteTreeW, RegSetValueExW,
997 };
998
999 let subkey = format!(r"Software\CodeWhaleTest\child_env_{}", std::process::id());
1000 let subkey_wide = windows_wide_null(OsStr::new(&subkey));
1001 let mut key = HKEY::default();
1002 let created =
1003 unsafe { RegCreateKeyW(HKEY_CURRENT_USER, PCWSTR(subkey_wide.as_ptr()), &mut key) };
1004 assert_eq!(created, ERROR_SUCCESS);
1005
1006 set_registry_string_value(key, "BIMRV_SDK_ROOT", r"F:\Lib\BimRv27.5");
1007 set_registry_string_value(key, "MY_SECRET_ROOT", r"F:\Secrets");
1008 let _ = unsafe { RegCloseKey(key) };
1009
1010 let mut candidates = Vec::new();
1011 append_windows_registry_env_key(&mut candidates, HKEY_CURRENT_USER, &subkey);
1012 let mut env = Vec::new();
1013 append_sanitized_child_env_candidates(&mut env, candidates);
1014
1015 let _ = unsafe { RegDeleteTreeW(HKEY_CURRENT_USER, PCWSTR(subkey_wide.as_ptr())) };
1016
1017 assert!(
1018 env.iter()
1019 .any(|(key, value)| key == "BIMRV_SDK_ROOT" && value == r"F:\Lib\BimRv27.5"),
1020 "registry child env should preserve custom SDK roots"
1021 );
1022 assert!(
1023 env.iter().all(|(key, _)| key != "MY_SECRET_ROOT"),
1024 "secret-like registry vars must still be dropped"
1025 );
1026
1027 fn set_registry_string_value(key: HKEY, name: &str, value: &str) {
1028 let name_wide = windows_wide_null(OsStr::new(name));
1029 let data = value
1030 .encode_utf16()
1031 .chain(std::iter::once(0))
1032 .flat_map(u16::to_le_bytes)
1033 .collect::<Vec<_>>();
1034 let status = unsafe {
1035 RegSetValueExW(key, PCWSTR(name_wide.as_ptr()), None, REG_SZ, Some(&data))
1036 };
1037 assert_eq!(status, ERROR_SUCCESS);
1038 }
1039 }
1040
1041 #[test]
1042 fn windows_common_program_files_defaults_replace_empty_values() {
1043 let mut env = vec![
1044 (OsString::from("CommonProgramFiles"), OsString::new()),
1045 (
1046 OsString::from("CommonProgramFiles(x86)"),
1047 OsString::from(" "),
1048 ),
1049 (
1050 OsString::from("CommonProgramW6432"),
1051 OsString::from(r"D:\Common Files"),
1052 ),
1053 ];
1054
1055 fill_windows_common_program_files(&mut env);
1056
1057 let get = |name: &str| {
1058 env.iter()
1059 .find(|(key, _)| normalize_key(key) == normalize_key(OsStr::new(name)))
1060 .map(|(_, value)| value.to_string_lossy().into_owned())
1061 };
1062 assert_eq!(
1063 get("CommonProgramFiles").as_deref(),
1064 Some(r"C:\Program Files\Common Files")
1065 );
1066 assert_eq!(
1067 get("CommonProgramFiles(x86)").as_deref(),
1068 Some(r"C:\Program Files (x86)\Common Files")
1069 );
1070 assert_eq!(
1071 get("CommonProgramW6432").as_deref(),
1072 Some(r"D:\Common Files")
1073 );
1074 }
1075
1076 #[test]
1077 fn mcp_env_allowlist_includes_python_bootstrap_keys() {
1078 for key in [
1079 "PYTHONPATH",
1080 "PYTHONHOME",
1081 "VIRTUAL_ENV",
1082 "PIPX_HOME",
1083 "PIPX_BIN_DIR",
1084 "POETRY_HOME",
1085 ] {
1086 assert!(
1087 is_allowed_mcp_env_key(OsStr::new(key)),
1088 "MCP allowlist should include python bootstrap key {key}"
1089 );
1090 }
1091 }
1092
1093 #[test]
1094 fn mcp_env_allowlist_includes_uv_prefixed_keys() {
1095 for key in ["UV_CACHE_DIR", "UV_INDEX_URL", "UV_PYTHON"] {
1096 assert!(
1097 is_allowed_mcp_env_key(OsStr::new(key)),
1098 "MCP allowlist should include uv prefixed key {key}"
1099 );
1100 }
1101 }
1102
1103 #[test]
1104 fn mcp_env_allowlist_includes_ca_bundles() {
1105 for key in [
1106 "SSL_CERT_FILE",
1107 "SSL_CERT_DIR",
1108 "REQUESTS_CA_BUNDLE",
1109 "CURL_CA_BUNDLE",
1110 ] {
1111 assert!(
1112 is_allowed_mcp_env_key(OsStr::new(key)),
1113 "MCP allowlist should include CA bundle key {key}"
1114 );
1115 }
1116 }
1117
1118 #[test]
1119 fn mcp_env_allowlist_includes_aws_profile_and_region_selectors() {
1120 for key in [
1121 "AWS_PROFILE",
1122 "AWS_REGION",
1123 "AWS_DEFAULT_REGION",
1124 "AWS_CONFIG_FILE",
1125 "AWS_SHARED_CREDENTIALS_FILE",
1126 ] {
1127 assert!(
1128 is_allowed_mcp_env_key(OsStr::new(key)),
1129 "MCP allowlist should include non-secret AWS selector {key}"
1130 );
1131 }
1132 }
1133
1134 #[test]
1135 fn mcp_env_allowlist_excludes_secrets_and_creds() {
1136 for key in [
1137 "AWS_SECRET_ACCESS_KEY",
1138 "AWS_ACCESS_KEY_ID",
1139 "AWS_SESSION_TOKEN",
1140 "AWS_SECURITY_TOKEN",
1141 "GITHUB_TOKEN",
1142 "OPENAI_API_KEY",
1143 "ANTHROPIC_API_KEY",
1144 "DEEPSEEK_API_KEY",
1145 "SLACK_TOKEN",
1146 "MY_RANDOM_SECRET",
1147 ] {
1148 assert!(
1149 !is_allowed_mcp_env_key(OsStr::new(key)),
1150 "MCP allowlist must NOT include {key}"
1151 );
1152 }
1153 }
1154
1155 #[test]
1156 fn sanitized_mcp_env_passes_through_node_bootstrap() {
1157 let _guard = crate::test_support::lock_test_env();
1158 let _nvm_dir = EnvVarGuard::set("NVM_DIR", "/tmp/test-nvm");
1159
1160 let env = sanitized_mcp_env(std::iter::empty::<(OsString, OsString)>());
1161
1162 let nvm_dir = env
1163 .iter()
1164 .find(|(key, _)| normalize_key(key) == "NVM_DIR")
1165 .map(|(_, value)| value.clone());
1166 assert_eq!(nvm_dir, Some(OsString::from("/tmp/test-nvm")));
1167 }
1168
1169 #[test]
1170 fn sanitized_mcp_env_drops_unrelated_secret_like_values() {
1171 let _guard = crate::test_support::lock_test_env();
1172 let _secret = EnvVarGuard::set("DEEPSEEK_MCP_TEST_SECRET", "should-not-leak");
1173
1174 let env = sanitized_mcp_env(std::iter::empty::<(OsString, OsString)>());
1175
1176 assert!(
1177 env.iter().all(|(key, _)| key != "DEEPSEEK_MCP_TEST_SECRET"),
1178 "MCP env should not pass arbitrary parent vars"
1179 );
1180 }
1181
1182 #[test]
1183 fn reviewed_plugin_mcp_env_requires_explicit_proxy_provenance() {
1184 let _guard = crate::test_support::lock_test_env();
1185 let synthetic_proxy = format!(
1186 "{}://{}:{}@{}",
1187 "http", "fixture-user", "fixture-password", "127.0.0.1:9"
1188 );
1189 let _proxy = EnvVarGuard::set("HTTP_PROXY", synthetic_proxy);
1190
1191 let ambient = sanitized_plugin_mcp_env(std::iter::empty::<(OsString, OsString)>());
1192 let explicit = sanitized_plugin_mcp_env([("HTTP_PROXY", "http://proxy.invalid")]);
1193
1194 assert!(
1195 ambient
1196 .iter()
1197 .all(|(key, _)| normalize_key(key) != "HTTP_PROXY"),
1198 "reviewed plugins must not inherit a credential-capable proxy URL"
1199 );
1200 assert!(explicit.iter().any(|(key, value)| {
1201 normalize_key(key) == "HTTP_PROXY" && value == "http://proxy.invalid"
1202 }));
1203 }
1204
1205 #[test]
1206 fn reviewed_plugin_mcp_env_keeps_desktop_routing_without_secret_namespaces() {
1207 let desktop = [
1208 ("DISPLAY", ":1"),
1209 ("WAYLAND_DISPLAY", "wayland-0"),
1210 ("XDG_RUNTIME_DIR", "/run/user/1000"),
1211 ("XDG_SESSION_TYPE", "wayland"),
1212 ("DBUS_SESSION_BUS_ADDRESS", "unix:path=/run/user/1000/bus"),
1213 ("XAUTHORITY", "/run/user/1000/.Xauthority"),
1214 ];
1215 let unexpected = [
1216 ("OPENAI_API_KEY", "provider-fixture"),
1217 ("XDG_PRIVATE_TOKEN", "xdg-fixture"),
1218 ("DBUS_PRIVATE_TOKEN", "dbus-fixture"),
1219 ("CODEWHALE_CU_APP_BUNDLE", "/stale/helper.app"),
1220 ];
1221 let child = sanitized_plugin_mcp_env_from(
1222 desktop.into_iter().chain(unexpected),
1223 std::iter::empty::<(&str, &str)>(),
1224 );
1225 for (key, value) in desktop {
1226 assert!(
1227 child
1228 .iter()
1229 .any(|(found, content)| found == key && content == value)
1230 );
1231 }
1232 for (key, _) in unexpected {
1233 assert!(child.iter().all(|(found, _)| found != key));
1234 }
1235 }
1236
1237 #[test]
1238 fn child_env_allowlist_keeps_build_config_but_not_credentials() {
1239 for key in [
1240 "CARGO_TARGET_DIR",
1241 "CARGO_BUILD_JOBS",
1242 "CARGO_INCREMENTAL",
1243 "RUSTFLAGS",
1244 "RUST_BACKTRACE",
1245 "RUST_LOG",
1246 "VIRTUAL_ENV",
1247 "JAVA_HOME",
1248 "GOPATH",
1249 "NVM_DIR",
1250 "NODE_OPTIONS",
1251 "SSL_CERT_FILE",
1252 "REQUESTS_CA_BUNDLE",
1253 ] {
1254 assert!(
1255 is_allowed_parent_env_key(OsStr::new(key)),
1256 "child env should keep {key}"
1257 );
1258 }
1259 for key in [
1260 "CARGO_REGISTRY_TOKEN",
1261 "CARGO_REGISTRIES_PRIVATE_TOKEN",
1262 "CARGO_REGISTRIES_PRIVATE_CREDENTIAL_PROVIDER",
1263 "CARGO_REGISTRY_GLOBAL_CREDENTIAL_PROVIDERS",
1264 "CARGO_SOME_SECRET",
1265 "CARGO_SIGNING_KEY",
1266 "DATABASE_URL",
1267 "OPENAI_API_KEY",
1268 ] {
1269 assert!(
1270 !is_allowed_parent_env_key(OsStr::new(key)),
1271 "child env must not keep {key}"
1272 );
1273 }
1274 }
1275
1276 #[test]
1277 fn proxy_userinfo_is_removed_before_reaching_a_child() {
1278 for (input, expected) in [
1279 ("http://user:secret@proxy:3128", "http://proxy:3128"),
1280 (
1281 "http://user:secret@proxy:3128/path?x=1",
1282 "http://proxy:3128/path?x=1",
1283 ),
1284 ("socks5h://u:p@w@10.0.0.1:1080", "socks5h://10.0.0.1:1080"),
1285 ("user:secret@proxy:3128", "proxy:3128"),
1286 ("http://proxy:3128", "http://proxy:3128"),
1287 ("http://proxy:3128/a@b", "http://proxy:3128/a@b"),
1288 ] {
1289 assert_eq!(
1290 strip_proxy_userinfo(OsStr::new(input)),
1291 Some(OsString::from(expected)),
1292 "{input}"
1293 );
1294 }
1295 }
1296
1297 #[test]
1298 fn sanitized_child_env_strips_proxy_credentials() {
1299 let _guard = crate::test_support::lock_test_env();
1300 let _https = EnvVarGuard::set(
1301 "HTTPS_PROXY",
1302 "http://fixture-user:fixture-pass@proxy.invalid:3128",
1303 );
1304 let _cargo = EnvVarGuard::set(
1305 "CARGO_HTTP_PROXY",
1306 "http://fixture-user:fixture-pass@proxy.invalid:3128",
1307 );
1308 let _no_proxy = EnvVarGuard::set("NO_PROXY", "localhost");
1309
1310 let env = sanitized_child_env(std::iter::empty::<(OsString, OsString)>());
1311 let get = |name: &str| {
1312 env.iter()
1313 .find(|(key, _)| normalize_key(key) == name)
1314 .map(|(_, value)| value.clone())
1315 };
1316 assert_eq!(
1317 get("HTTPS_PROXY"),
1318 Some(OsString::from("http://proxy.invalid:3128"))
1319 );
1320 assert_eq!(
1321 get("CARGO_HTTP_PROXY"),
1322 Some(OsString::from("http://proxy.invalid:3128"))
1323 );
1324 assert_eq!(get("NO_PROXY"), Some(OsString::from("localhost")));
1325 assert!(
1326 env.iter()
1327 .all(|(_, value)| !value.to_string_lossy().contains("fixture-pass"))
1328 );
1329 // Explicit call-site overrides are trusted and kept as given.
1330 let explicit = sanitized_child_env([("HTTPS_PROXY", "http://a:b@proxy.invalid:1")]);
1331 assert!(explicit.iter().any(|(key, value)| {
1332 normalize_key(key) == "HTTPS_PROXY" && value == "http://a:b@proxy.invalid:1"
1333 }));
1334 }
1335
1336 #[test]
1337 fn sanitized_child_env_drops_parent_secret_like_values() {
1338 let _guard = crate::test_support::lock_test_env();
1339 let _secret = EnvVarGuard::set("DEEPSEEK_CHILD_ENV_TEST_SECRET", "parent-secret");
1340
1341 let env = sanitized_child_env(std::iter::empty::<(OsString, OsString)>());
1342
1343 assert!(
1344 env.iter()
1345 .all(|(key, _)| key != "DEEPSEEK_CHILD_ENV_TEST_SECRET")
1346 );
1347 }
1348
1349 #[test]
1350 fn sanitized_child_env_drops_rustc_wrapper_under_os_sandbox() {
1351 let _guard = crate::test_support::lock_test_env();
1352 let _wrapper = EnvVarGuard::set("RUSTC_WRAPPER", "sccache");
1353
1354 let unsandboxed = sanitized_child_env(std::iter::empty::<(OsString, OsString)>());
1355 assert!(
1356 unsandboxed
1357 .iter()
1358 .any(|(key, value)| normalize_key(key) == "RUSTC_WRAPPER" && value == "sccache"),
1359 "unsandboxed children keep RUSTC_WRAPPER"
1360 );
1361
1362 let sandboxed = sanitized_child_env([(
1363 OsString::from("CODEWHALE_SANDBOX"),
1364 OsString::from("seatbelt"),
1365 )]);
1366 assert!(
1367 sandboxed.iter().any(
1368 |(key, value)| normalize_key(key) == "CODEWHALE_SANDBOX" && value == "seatbelt"
1369 ),
1370 "sandbox marker must survive"
1371 );
1372 assert!(
1373 sandboxed
1374 .iter()
1375 .all(|(key, _)| normalize_key(key) != "RUSTC_WRAPPER"),
1376 "OS sandbox children must not keep RUSTC_WRAPPER"
1377 );
1378 }
1379
1380 #[test]
1381 fn explicit_child_env_values_win_over_parent_allowlist() {
1382 // The real parent PATH is enough to prove the override wins; setting
1383 // PATH here would break every concurrent test that spawns a program.
1384 assert_ne!(
1385 std::env::var_os("PATH"),
1386 Some(OsString::from("/explicit/bin"))
1387 );
1388
1389 let env = sanitized_child_env([(OsString::from("PATH"), OsString::from("/explicit/bin"))]);
1390
1391 let path = env
1392 .iter()
1393 .find(|(key, _)| normalize_key(key) == "PATH")
1394 .map(|(_, value)| value);
1395 assert_eq!(path, Some(&OsString::from("/explicit/bin")));
1396 }
1397
1398 #[test]
1399 fn sanitized_child_env_preserves_windows_toolchain_vars() {
1400 let _guard = crate::test_support::lock_test_env();
1401 let _lib = EnvVarGuard::set("LIB", r"C:\sdk\lib");
1402 let _include = EnvVarGuard::set("INCLUDE", r"C:\sdk\include");
1403 let _sdk = EnvVarGuard::set("WINDOWSSDKDIR", r"C:\sdk");
1404
1405 let env = sanitized_child_env(std::iter::empty::<(OsString, OsString)>());
1406
1407 assert!(
1408 env.iter()
1409 .any(|(key, value)| key == "LIB" && value == r"C:\sdk\lib"),
1410 "child env should preserve LIB"
1411 );
1412 assert!(
1413 env.iter()
1414 .any(|(key, value)| key == "INCLUDE" && value == r"C:\sdk\include"),
1415 "child env should preserve INCLUDE"
1416 );
1417 assert!(
1418 env.iter()
1419 .any(|(key, value)| key == "WINDOWSSDKDIR" && value == r"C:\sdk"),
1420 "child env should preserve WINDOWSSDKDIR"
1421 );
1422 }
1423 }
1424
1424 lines RUST