use crate::security::is_credential_like_key;
use std::{
collections::BTreeMap,
ffi::{OsStr, OsString},
process::Command,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SubprocessEnvProfile {
Shell,
Lsp,
McpStdio,
}
#[derive(Debug, Clone, Copy)]
struct ProfileRules {
exact: &'static [&'static str],
locale: &'static [&'static str],
include_windows_startup: bool,
}
const EMPTY: &[&str] = &[];
const LOCALE_VARIABLES: &[&str] = &[
"LC_CTYPE",
"LC_NUMERIC",
"LC_TIME",
"LC_COLLATE",
"LC_MONETARY",
"LC_MESSAGES",
"LC_PAPER",
"LC_NAME",
"LC_ADDRESS",
"LC_TELEPHONE",
"LC_MEASUREMENT",
"LC_IDENTIFICATION",
"LC_ALL",
];
const SHELL_VARIABLES: &[&str] = &["PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "LANG"];
const PATH_ONLY: &[&str] = &["PATH"];
#[cfg(windows)]
fn matches_environment_name(actual: &str, expected: &str) -> bool {
actual.eq_ignore_ascii_case(expected)
}
#[cfg(not(windows))]
fn matches_environment_name(actual: &str, expected: &str) -> bool {
actual == expected
}
impl SubprocessEnvProfile {
fn rules(self) -> ProfileRules {
match self {
Self::Shell => ProfileRules {
exact: SHELL_VARIABLES,
locale: LOCALE_VARIABLES,
include_windows_startup: true,
},
Self::Lsp => ProfileRules {
exact: PATH_ONLY,
locale: EMPTY,
include_windows_startup: false,
},
Self::McpStdio => ProfileRules {
exact: PATH_ONLY,
locale: EMPTY,
include_windows_startup: true,
},
}
}
pub(crate) fn allows_ambient(self, key: &OsStr) -> bool {
let Some(key) = key.to_str() else {
return false;
};
if is_credential_like_key(key) {
return false;
}
let rules = self.rules();
(rules.include_windows_startup && is_windows_startup_variable(key))
|| rules
.exact
.iter()
.any(|expected| matches_environment_name(key, expected))
|| rules
.locale
.iter()
.any(|expected| matches_environment_name(key, expected))
}
}
pub(crate) fn apply_profile(command: &mut Command, profile: SubprocessEnvProfile) {
apply_profile_from(command, profile, std::env::vars_os());
}
pub(crate) fn apply_configured_profile(
command: &mut Command,
profile: SubprocessEnvProfile,
configured: &BTreeMap<String, String>,
) {
apply_configured_profile_from(command, profile, std::env::vars_os(), configured);
}
fn apply_profile_from(
command: &mut Command,
profile: SubprocessEnvProfile,
environment: impl IntoIterator<Item = (OsString, OsString)>,
) {
command.env_clear();
for (key, value) in environment {
if profile.allows_ambient(&key) {
command.env(key, value);
}
}
}
fn apply_configured_profile_from(
command: &mut Command,
profile: SubprocessEnvProfile,
environment: impl IntoIterator<Item = (OsString, OsString)>,
configured: &BTreeMap<String, String>,
) {
apply_profile_from(command, profile, environment);
command.envs(configured);
}
#[cfg(windows)]
fn is_windows_startup_variable(key: &str) -> bool {
["ComSpec", "PATHEXT", "SystemRoot", "WINDIR"]
.iter()
.any(|name| matches_environment_name(key, name))
}
#[cfg(not(windows))]
fn is_windows_startup_variable(_key: &str) -> bool {
false
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn environment(entries: &[(&str, &str)]) -> Vec<(OsString, OsString)> {
entries
.iter()
.map(|(key, value)| (OsString::from(key), OsString::from(value)))
.collect()
}
fn command_environment(command: &Command) -> BTreeMap<String, String> {
command
.get_envs()
.filter_map(|(key, value)| {
value.map(|value| {
(
key.to_string_lossy().into_owned(),
value.to_string_lossy().into_owned(),
)
})
})
.collect()
}
fn all_profiles() -> [SubprocessEnvProfile; 3] {
[
SubprocessEnvProfile::Shell,
SubprocessEnvProfile::Lsp,
SubprocessEnvProfile::McpStdio,
]
}
#[test]
fn profiles_keep_distinct_allowlists() {
assert!(SubprocessEnvProfile::Shell.allows_ambient(OsStr::new("USER")));
assert!(SubprocessEnvProfile::Shell.allows_ambient(OsStr::new("SHELL")));
assert!(!SubprocessEnvProfile::Shell.allows_ambient(OsStr::new("DISPLAY")));
assert!(SubprocessEnvProfile::Shell.allows_ambient(OsStr::new("LC_ALL")));
assert!(!SubprocessEnvProfile::Lsp.allows_ambient(OsStr::new("HOME")));
assert!(SubprocessEnvProfile::McpStdio.allows_ambient(OsStr::new("PATH")));
}
#[test]
fn exact_and_windows_startup_names_follow_platform_case_rules() {
assert!(SubprocessEnvProfile::Shell.allows_ambient(OsStr::new("PATH")));
assert_eq!(
SubprocessEnvProfile::Shell.allows_ambient(OsStr::new("Path")),
cfg!(windows)
);
assert_eq!(
SubprocessEnvProfile::Shell.allows_ambient(OsStr::new("path")),
cfg!(windows)
);
for name in ["SystemRoot", "systemroot", "SYSTEMROOT"] {
assert_eq!(
SubprocessEnvProfile::Shell.allows_ambient(OsStr::new(name)),
cfg!(windows),
"unexpected startup-variable match for {name}"
);
}
}
#[test]
fn locale_allowlist_uses_exact_standard_names_and_platform_case_rules() {
let locale_profiles = [SubprocessEnvProfile::Shell];
for profile in locale_profiles {
for name in [
"LANG",
"LC_CTYPE",
"LC_NUMERIC",
"LC_TIME",
"LC_COLLATE",
"LC_MONETARY",
"LC_MESSAGES",
"LC_PAPER",
"LC_NAME",
"LC_ADDRESS",
"LC_TELEPHONE",
"LC_MEASUREMENT",
"LC_IDENTIFICATION",
"LC_ALL",
] {
assert!(
profile.allows_ambient(OsStr::new(name)),
"{profile:?} rejected standard locale variable {name}"
);
}
}
for name in ["LC_PASSWORD", "LC_PRIVATE_KEY", "LC_API_TOKEN"] {
for profile in all_profiles() {
assert!(
!profile.allows_ambient(OsStr::new(name)),
"{profile:?} accepted non-standard locale variable {name}"
);
}
}
for name in ["lc_ALL", "Lc_All", "lC_ctype"] {
assert_eq!(
SubprocessEnvProfile::Shell.allows_ambient(OsStr::new(name)),
cfg!(windows),
"unexpected locale-name case match for {name}"
);
}
assert!(!SubprocessEnvProfile::Shell.allows_ambient(OsStr::new("LC")));
}
#[test]
fn credential_like_ambient_names_are_rejected_for_every_profile() {
for profile in all_profiles() {
for name in [
"OPENAI_API_KEY",
"MC_API_KEY",
"ANTHROPIC_API_KEY",
"AWS_SECRET_ACCESS_KEY",
"MCP_TOKEN",
"LC_API_KEY",
] {
assert!(
!profile.allows_ambient(OsStr::new(name)),
"{profile:?} accepted ambient credential {name}"
);
}
}
}
#[test]
fn windows_startup_baseline_is_profile_specific_and_portable() {
let baseline = ["ComSpec", "PATHEXT", "SystemRoot", "WINDIR"];
for name in baseline {
#[cfg(windows)]
{
assert!(SubprocessEnvProfile::Shell.allows_ambient(OsStr::new(name)));
assert!(SubprocessEnvProfile::McpStdio.allows_ambient(OsStr::new(name)));
assert!(!SubprocessEnvProfile::Lsp.allows_ambient(OsStr::new(name)));
}
#[cfg(not(windows))]
{
assert!(!SubprocessEnvProfile::Shell.allows_ambient(OsStr::new(name)));
}
}
}
#[test]
fn apply_profile_clears_existing_values_and_applies_only_selected_ambient_values() {
let mut command = Command::new("profile-test");
command.env("STALE_VALUE", "must disappear");
apply_profile_from(
&mut command,
SubprocessEnvProfile::Shell,
environment(&[
("PATH", "path"),
("HOME", "home"),
("TMPDIR", "tmp"),
("LANG", "lang"),
("LC_ALL", "locale"),
("LC_API_KEY", "credential"),
("OPENAI_API_KEY", "credential"),
("DISPLAY", "display"),
]),
);
let actual = command_environment(&command);
assert_eq!(actual.get("PATH"), Some(&"path".to_string()));
assert_eq!(actual.get("HOME"), Some(&"home".to_string()));
assert_eq!(actual.get("LC_ALL"), Some(&"locale".to_string()));
assert!(!actual.contains_key("STALE_VALUE"));
assert!(!actual.contains_key("LC_API_KEY"));
assert!(!actual.contains_key("OPENAI_API_KEY"));
assert!(!actual.contains_key("DISPLAY"));
}
#[test]
fn configured_mcp_values_override_ambient_values_after_filtering() {
let mut command = Command::new("mcp-profile-test");
let configured = BTreeMap::from([
("PATH".to_string(), "configured-path".to_string()),
("MCP_TOKEN".to_string(), "configured-token".to_string()),
("MCP_ROOT".to_string(), "/configured/root".to_string()),
]);
apply_configured_profile_from(
&mut command,
SubprocessEnvProfile::McpStdio,
environment(&[
("PATH", "ambient-path"),
("MCP_TOKEN", "ambient-token"),
("OPENAI_API_KEY", "ambient-key"),
("HOME", "ambient-home"),
]),
&configured,
);
let actual = command_environment(&command);
assert_eq!(actual.get("PATH"), Some(&"configured-path".to_string()));
assert_eq!(
actual.get("MCP_TOKEN"),
Some(&"configured-token".to_string())
);
assert_eq!(
actual.get("MCP_ROOT"),
Some(&"/configured/root".to_string())
);
assert!(!actual.contains_key("OPENAI_API_KEY"));
assert!(!actual.contains_key("HOME"));
}
}