use std::collections::BTreeMap;
use super::session_environment::{
EnvironmentPolicyError, EnvironmentPolicyKind, SessionEnvironment,
};
const BASE_ENV_ALLOWLIST: &[&str] = &[
"PATH",
"HOME",
"USER",
"LOGNAME",
"SHELL",
"PWD",
"TERM",
"TZ",
"HOSTNAME",
"COLUMNS",
"LINES",
"TMPDIR",
"TMP",
"TEMP",
"LANG",
"LANGUAGE",
"LC_ALL",
"LC_CTYPE",
"LC_MESSAGES",
"XDG_CACHE_HOME",
"XDG_CONFIG_HOME",
"XDG_DATA_HOME",
"SSL_CERT_FILE",
"SSL_CERT_DIR",
"CURL_CA_BUNDLE",
];
const TOOLCHAIN_ENV_ALLOWLIST: &[&str] = &[
"CARGO_HOME",
"CARGO_TARGET_DIR",
"RUSTUP_HOME",
"RUST_BACKTRACE",
"RUST_LOG",
"NODE_PATH",
"NPM_CONFIG_CACHE",
"NPM_CONFIG_STORE_DIR",
"NVM_DIR",
"PNPM_HOME",
"YARN_CACHE_FOLDER",
"PIP_CACHE_DIR",
"PYENV_ROOT",
"PYTHONPATH",
"PYTHONUSERBASE",
"UV_CACHE_DIR",
"VIRTUAL_ENV",
"GOCACHE",
"GOMODCACHE",
"GOPATH",
"GOROOT",
"JAVA_HOME",
"AR",
"CC",
"CXX",
"LD",
];
pub const TOOLCHAIN_PATH_ENV_VARS: &[&str] = &[
"CARGO_HOME",
"CARGO_TARGET_DIR",
"RUSTUP_HOME",
"NODE_PATH",
"NPM_CONFIG_CACHE",
"NPM_CONFIG_STORE_DIR",
"NVM_DIR",
"PNPM_HOME",
"YARN_CACHE_FOLDER",
"PIP_CACHE_DIR",
"PYENV_ROOT",
"PYTHONPATH",
"PYTHONUSERBASE",
"UV_CACHE_DIR",
"VIRTUAL_ENV",
"GOCACHE",
"GOMODCACHE",
"GOPATH",
"GOROOT",
"JAVA_HOME",
];
pub const TOOLCHAIN_CACHE_ENV_VARS: &[&str] = &[
"CARGO_HOME",
"CARGO_TARGET_DIR",
"GOCACHE",
"GOMODCACHE",
"GOPATH",
"NPM_CONFIG_CACHE",
"NPM_CONFIG_STORE_DIR",
"PIP_CACHE_DIR",
"PNPM_HOME",
"RUSTUP_HOME",
"UV_CACHE_DIR",
"YARN_CACHE_FOLDER",
];
pub const ENV_ALLOWLIST: &[&str] = &const_concat();
const fn const_concat() -> [&'static str; BASE_ENV_ALLOWLIST.len() + TOOLCHAIN_ENV_ALLOWLIST.len()]
{
let mut out: [&'static str; BASE_ENV_ALLOWLIST.len() + TOOLCHAIN_ENV_ALLOWLIST.len()] =
[""; BASE_ENV_ALLOWLIST.len() + TOOLCHAIN_ENV_ALLOWLIST.len()];
let mut i = 0;
while i < BASE_ENV_ALLOWLIST.len() {
out[i] = BASE_ENV_ALLOWLIST[i];
i += 1;
}
let mut j = 0;
while j < TOOLCHAIN_ENV_ALLOWLIST.len() {
out[BASE_ENV_ALLOWLIST.len() + j] = TOOLCHAIN_ENV_ALLOWLIST[j];
j += 1;
}
out
}
pub fn resolve_env(
environment: &SessionEnvironment,
_env_lookup: &dyn Fn(&str) -> Option<String>,
resolve_secret: &dyn Fn(&str, &str) -> Option<String>,
) -> Result<BTreeMap<String, String>, EnvironmentPolicyError> {
if matches!(environment.kind(), EnvironmentPolicyKind::Inherited) {
return Ok(environment.launcher_snapshot().clone());
}
let mut env = BTreeMap::new();
for name in ENV_ALLOWLIST {
if let Some(value) = environment.launcher_value(name) {
env.insert((*name).to_string(), value.to_string());
}
}
for (var, value) in environment.env_exposure(resolve_secret)? {
env.insert(var, value);
}
Ok(env)
}
pub fn lookup_env(
environment: &SessionEnvironment,
name: &str,
_env_lookup: &dyn Fn(&str) -> Option<String>,
resolve_secret: &dyn Fn(&str, &str) -> Option<String>,
) -> Result<Option<String>, EnvironmentPolicyError> {
if let Some(value) = environment.env_exposure_for(name, resolve_secret)? {
return Ok(Some(value));
}
if matches!(environment.kind(), EnvironmentPolicyKind::Inherited)
|| ENV_ALLOWLIST.contains(&name)
{
return Ok(environment.launcher_value(name).map(str::to_string));
}
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::security::session_environment::{EnvironmentPolicyKind, GrantSourceSpec, GrantSpec};
#[test]
fn toolchain_path_env_vars_are_a_subset_of_the_allowlist() {
for name in TOOLCHAIN_PATH_ENV_VARS {
assert!(
TOOLCHAIN_ENV_ALLOWLIST.contains(name),
"{name} is in TOOLCHAIN_PATH_ENV_VARS but not TOOLCHAIN_ENV_ALLOWLIST"
);
}
for name in TOOLCHAIN_CACHE_ENV_VARS {
assert!(
TOOLCHAIN_PATH_ENV_VARS.contains(name),
"{name} is in TOOLCHAIN_CACHE_ENV_VARS but not TOOLCHAIN_PATH_ENV_VARS"
);
}
}
fn looks_like_secret(name: &str) -> bool {
let upper = name.to_ascii_uppercase();
const SECRET_PREFIXES: &[&str] = &[
"ANTHROPIC_",
"OPENAI_",
"OPENROUTER_",
"FIREWORKS_",
"TOGETHER_",
"XAI_",
"GROQ_",
"AWS_",
];
const SECRET_SUFFIXES: &[&str] = &[
"_API_KEY",
"_TOKEN",
"_SECRET",
"_KEY",
"_PASSWORD",
"_PASSWD",
"_CREDENTIALS",
];
SECRET_PREFIXES.iter().any(|p| upper.starts_with(p))
|| SECRET_SUFFIXES.iter().any(|s| upper.ends_with(s))
}
#[test]
fn allowlist_is_single_owned_and_secret_free() {
let mut seen = std::collections::BTreeSet::new();
for name in ENV_ALLOWLIST {
assert!(seen.insert(*name), "duplicate allowlist entry: {name}");
}
for name in ENV_ALLOWLIST {
assert!(
!looks_like_secret(name),
"allowlist admits a secret-shaped variable '{name}' — a credential must \
cross via a grant, never the allowlist"
);
}
for provider in crate::llm_config::provider_names() {
let Some(definition) = crate::llm_config::provider_config(&provider) else {
continue;
};
for auth_env in crate::llm_config::auth_env_names(&definition.auth_env) {
assert!(
!ENV_ALLOWLIST.contains(&auth_env.as_str()),
"allowlist admits '{auth_env}', the credential variable provider \
'{provider}' declares — a credential must cross via a grant"
);
}
}
for required in ["PATH", "HOME", "TMPDIR", "LANG"] {
assert!(
ENV_ALLOWLIST.contains(&required),
"base allowlist missing essential '{required}'"
);
}
}
fn env_from(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<String> {
move |var: &str| {
pairs
.iter()
.find(|(name, _)| *name == var)
.map(|(_, value)| value.to_string())
}
}
#[test]
fn isolated_environment_is_the_allowlist_alone() {
let parent = env_from(&[
("PATH", "/usr/bin"),
("HOME", "/home/agent"),
("ANTHROPIC_API_KEY", "sk-secret"),
("SOME_UNLISTED_VAR", "whatever"),
]);
let never_secret = |_: &str, _: &str| None;
let environment = SessionEnvironment::launch_from_snapshot(
EnvironmentPolicyKind::Isolated,
Vec::new(),
BTreeMap::from([
("PATH".to_string(), "/usr/bin".to_string()),
("HOME".to_string(), "/home/agent".to_string()),
("ANTHROPIC_API_KEY".to_string(), "sk-secret".to_string()),
("SOME_UNLISTED_VAR".to_string(), "whatever".to_string()),
]),
&parent,
)
.unwrap();
let env = resolve_env(&environment, &parent, &never_secret).unwrap();
assert_eq!(env.get("PATH").map(String::as_str), Some("/usr/bin"));
assert_eq!(env.get("HOME").map(String::as_str), Some("/home/agent"));
assert!(
!env.contains_key("ANTHROPIC_API_KEY"),
"isolated env must not inherit a secret from the parent"
);
assert!(
!env.contains_key("SOME_UNLISTED_VAR"),
"isolated env must not inherit an unlisted var"
);
}
#[test]
fn granted_env_is_allowlist_plus_grants_via_the_same_resolver() {
let parent = env_from(&[("PATH", "/usr/bin"), ("FIREWORKS_API_KEY", "fw-secret")]);
let specs = vec![
GrantSpec {
name: "fireworks".to_string(),
source: GrantSourceSpec::Env {
var: "FIREWORKS_API_KEY".to_string(),
},
expose_as_env: Some("FIREWORKS_API_KEY".to_string()),
},
GrantSpec {
name: "gh".to_string(),
source: GrantSourceSpec::SecretStore {
account: "gh".to_string(),
key: "token".to_string(),
},
expose_as_env: Some("GH_TOKEN".to_string()),
},
];
let environment =
SessionEnvironment::launch(EnvironmentPolicyKind::Granted, specs, &parent).unwrap();
let resolve_secret = |account: &str, key: &str| {
(account == "gh" && key == "token").then(|| "ghp".to_string())
};
let env = resolve_env(&environment, &parent, &resolve_secret).unwrap();
assert_eq!(env.get("PATH").map(String::as_str), Some("/usr/bin"));
assert_eq!(
env.get("FIREWORKS_API_KEY").map(String::as_str),
Some("fw-secret")
);
assert_eq!(env.get("GH_TOKEN").map(String::as_str), Some("ghp"));
}
#[test]
fn single_name_lookup_agrees_with_the_full_resolver() {
let parent = env_from(&[
("PATH", "/usr/bin"),
("RUST_LOG", "from_parent"),
("FIREWORKS_API_KEY", "fw-secret"),
("ANTHROPIC_API_KEY", "sk-launcher"),
("SOME_UNLISTED_VAR", "whatever"),
]);
let specs = vec![
GrantSpec {
name: "fireworks".to_string(),
source: GrantSourceSpec::Env {
var: "FIREWORKS_API_KEY".to_string(),
},
expose_as_env: Some("FIREWORKS_API_KEY".to_string()),
},
GrantSpec {
name: "log".to_string(),
source: GrantSourceSpec::Env {
var: "RUST_LOG".to_string(),
},
expose_as_env: Some("RUST_LOG".to_string()),
},
GrantSpec {
name: "gh".to_string(),
source: GrantSourceSpec::SecretStore {
account: "gh".to_string(),
key: "token".to_string(),
},
expose_as_env: Some("GH_TOKEN".to_string()),
},
];
let resolve_secret = |account: &str, key: &str| {
(account == "gh" && key == "token").then(|| "ghp".to_string())
};
let probes = [
"PATH",
"RUST_LOG",
"FIREWORKS_API_KEY",
"ANTHROPIC_API_KEY",
"GH_TOKEN",
"SOME_UNLISTED_VAR",
"HOME",
];
for environment in [
SessionEnvironment::isolated(),
SessionEnvironment::launch(EnvironmentPolicyKind::Granted, specs, &parent).unwrap(),
] {
let map = resolve_env(&environment, &parent, &resolve_secret).unwrap();
for name in probes {
assert_eq!(
lookup_env(&environment, name, &parent, &resolve_secret).unwrap(),
map.get(name).cloned(),
"lookup_env disagreed with resolve_env for {name} under {:?}",
environment.kind()
);
}
}
}
#[test]
fn a_single_name_lookup_touches_only_its_own_grant() {
let parent = env_from(&[("FIREWORKS_API_KEY", "fw-secret")]);
let specs = vec![
GrantSpec {
name: "fireworks".to_string(),
source: GrantSourceSpec::Env {
var: "FIREWORKS_API_KEY".to_string(),
},
expose_as_env: Some("FIREWORKS_API_KEY".to_string()),
},
GrantSpec {
name: "broken".to_string(),
source: GrantSourceSpec::SecretStore {
account: "vault".to_string(),
key: "absent".to_string(),
},
expose_as_env: Some("OTHER_TOKEN".to_string()),
},
];
let environment =
SessionEnvironment::launch(EnvironmentPolicyKind::Granted, specs, &parent).unwrap();
let explode = |_: &str, _: &str| -> Option<String> {
panic!("an unrelated grant's secret must not be resolved")
};
assert_eq!(
lookup_env(&environment, "FIREWORKS_API_KEY", &parent, &explode).unwrap(),
Some("fw-secret".to_string())
);
assert_eq!(
lookup_env(&environment, "UNRELATED_VAR", &parent, &explode).unwrap(),
None
);
let missing = |_: &str, _: &str| -> Option<String> { None };
assert_eq!(
lookup_env(&environment, "OTHER_TOKEN", &parent, &missing),
Err(EnvironmentPolicyError::MissingSecret {
name: "broken".to_string()
})
);
assert_eq!(
lookup_env(&environment, "FIREWORKS_API_KEY", &parent, &missing).unwrap(),
Some("fw-secret".to_string()),
"one unresolvable grant must not mask a healthy one"
);
}
#[test]
fn isolated_hides_a_launcher_credential_from_the_in_process_reader() {
let parent = env_from(&[("PATH", "/usr/bin"), ("ANTHROPIC_API_KEY", "sk-launcher")]);
let never_secret = |_: &str, _: &str| None;
let isolated = SessionEnvironment::launch_from_snapshot(
EnvironmentPolicyKind::Isolated,
Vec::new(),
BTreeMap::from([
("PATH".to_string(), "/usr/bin".to_string()),
("ANTHROPIC_API_KEY".to_string(), "sk-launcher".to_string()),
]),
&parent,
)
.unwrap();
assert_eq!(
lookup_env(&isolated, "ANTHROPIC_API_KEY", &parent, &never_secret).unwrap(),
None
);
assert_eq!(
lookup_env(&isolated, "PATH", &parent, &never_secret).unwrap(),
Some("/usr/bin".to_string())
);
}
#[test]
fn a_grant_may_re_expose_an_allowlisted_name_and_wins() {
let parent = env_from(&[("RUST_LOG", "from_parent")]);
let specs = vec![GrantSpec {
name: "log".to_string(),
source: GrantSourceSpec::Env {
var: "RUST_LOG".to_string(),
},
expose_as_env: Some("RUST_LOG".to_string()),
}];
let environment =
SessionEnvironment::launch(EnvironmentPolicyKind::Granted, specs, &parent).unwrap();
let never_secret = |_: &str, _: &str| None;
let env = resolve_env(&environment, &parent, &never_secret).unwrap();
assert_eq!(env.get("RUST_LOG").map(String::as_str), Some("from_parent"));
}
}