use std::collections::BTreeMap;
use super::session_grants::{GrantError, SessionProfile};
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",
"NVM_DIR",
"PNPM_HOME",
"PYENV_ROOT",
"PYTHONPATH",
"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",
"NVM_DIR",
"PNPM_HOME",
"PYENV_ROOT",
"PYTHONPATH",
"UV_CACHE_DIR",
"VIRTUAL_ENV",
"GOCACHE",
"GOMODCACHE",
"GOPATH",
"GOROOT",
"JAVA_HOME",
];
pub const TOOLCHAIN_CACHE_ENV_VARS: &[&str] = &[
"CARGO_HOME",
"GOCACHE",
"GOMODCACHE",
"GOPATH",
"NPM_CONFIG_CACHE",
"PNPM_HOME",
"RUSTUP_HOME",
"UV_CACHE_DIR",
];
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(
profile: &SessionProfile,
env_lookup: &dyn Fn(&str) -> Option<String>,
resolve_secret: &dyn Fn(&str, &str) -> Option<String>,
) -> Result<BTreeMap<String, String>, GrantError> {
let mut env = BTreeMap::new();
for name in ENV_ALLOWLIST {
if let Some(value) = env_lookup(name) {
env.insert((*name).to_string(), value);
}
}
for (var, value) in profile.env_exposure(resolve_secret)? {
env.insert(var, value);
}
Ok(env)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::security::session_grants::{GrantSourceSpec, GrantSpec, SessionProfileKind};
#[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 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 hermetic_env_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 profile = SessionProfile::hermetic();
let env = resolve_env(&profile, &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"),
"hermetic env must not inherit a secret from the parent"
);
assert!(
!env.contains_key("SOME_UNLISTED_VAR"),
"hermetic env must not inherit an unlisted var"
);
}
#[test]
fn lane_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 profile = SessionProfile::launch(SessionProfileKind::Lane, specs, &parent).unwrap();
let resolve_secret = |account: &str, key: &str| {
(account == "gh" && key == "token").then(|| "ghp".to_string())
};
let env = resolve_env(&profile, &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 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 profile = SessionProfile::launch(SessionProfileKind::Lane, specs, &parent).unwrap();
let never_secret = |_: &str, _: &str| None;
let env = resolve_env(&profile, &parent, &never_secret).unwrap();
assert_eq!(env.get("RUST_LOG").map(String::as_str), Some("from_parent"));
}
}