use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::ffi::OsString;
pub const INHERITED_ENV: &[&str] = &[
"PATH",
"PATHEXT",
"SYSTEMROOT",
"WINDIR",
"ComSpec",
"TMPDIR",
"TMP",
"TEMP",
"LANG",
"LC_ALL",
"LC_CTYPE",
"HOME",
"USERPROFILE",
"USER",
"LOGNAME",
"SHELL",
"XDG_RUNTIME_DIR",
"TERM_PROGRAM",
];
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum EnvValue {
Literal(String),
Source(EnvSource),
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub enum EnvSource {
HostEnv { host_env: String },
Secret { secret: String },
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub enum EnvPolicy {
Sanitized {
#[serde(default)]
inherit: Vec<String>,
#[serde(default)]
set: BTreeMap<String, EnvValue>,
},
InheritAll {
#[serde(default)]
set: BTreeMap<String, EnvValue>,
},
}
impl Default for EnvPolicy {
fn default() -> Self {
Self::Sanitized {
inherit: Vec::new(),
set: BTreeMap::new(),
}
}
}
const TEMP_DIR_VARS: &[&str] = &["TMPDIR", "TMP", "TEMP"];
fn inheritable_temp_dir(key: &str, value: &OsString) -> bool {
if !TEMP_DIR_VARS.contains(&key) {
return true;
}
let value = value.to_string_lossy();
!(value.starts_with("/dev/fd/") || value.starts_with("/proc/self/fd/"))
}
fn inheritable_host_binding(key: &str) -> bool {
!key.ends_with("_EXT_HOST_PID")
}
#[async_trait::async_trait]
pub trait SecretResolver: Send + Sync {
async fn resolve(&self, reference: &str) -> Option<String>;
}
pub struct NoSecrets;
#[async_trait::async_trait]
impl SecretResolver for NoSecrets {
async fn resolve(&self, _reference: &str) -> Option<String> {
None
}
}
pub async fn resolve(
policy: &EnvPolicy,
extra: &BTreeMap<String, String>,
secrets: &dyn SecretResolver,
) -> crate::Result<BTreeMap<String, OsString>> {
let mut resolved: BTreeMap<String, OsString> = BTreeMap::new();
match policy {
EnvPolicy::Sanitized { inherit, set } => {
for key in INHERITED_ENV
.iter()
.copied()
.chain(inherit.iter().map(String::as_str))
{
if let Some(value) = std::env::var_os(key)
.filter(|v| !v.is_empty())
.filter(|v| inheritable_temp_dir(key, v))
{
resolved.insert(key.to_owned(), value);
}
}
resolved.insert("TERM".into(), OsString::from("dumb"));
resolved.insert("NO_COLOR".into(), OsString::from("1"));
apply_set(&mut resolved, set, secrets).await?;
}
EnvPolicy::InheritAll { set } => {
for (key, value) in std::env::vars_os() {
let key = key.into_string().unwrap_or_default();
if inheritable_temp_dir(&key, &value) && inheritable_host_binding(&key) {
resolved.insert(key, value);
}
}
apply_set(&mut resolved, set, secrets).await?;
}
}
for (key, value) in extra {
resolved.insert(key.clone(), OsString::from(value));
}
Ok(resolved)
}
async fn apply_set(
resolved: &mut BTreeMap<String, OsString>,
set: &BTreeMap<String, EnvValue>,
secrets: &dyn SecretResolver,
) -> crate::Result<()> {
for (name, value) in set {
let value = match value {
EnvValue::Literal(v) => Some(v.clone()),
EnvValue::Source(EnvSource::HostEnv { host_env }) => std::env::var(host_env).ok(),
EnvValue::Source(EnvSource::Secret { secret }) => secrets.resolve(secret).await,
}
.ok_or_else(|| {
crate::error::AdapterError::permanent("agent_env_source_unavailable", "env/resolve")
.with_detail(format!("environment entry `{name}` could not be resolved"))
})?;
resolved.insert(name.clone(), OsString::from(value));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const MISSING_REF: &str = "missing-ref";
const STORED_REF: &str = "stored-ref";
const RESOLVED_VALUE: &str = "resolved-value";
struct Resolver(Option<String>);
#[async_trait::async_trait]
impl SecretResolver for Resolver {
async fn resolve(&self, _reference: &str) -> Option<String> {
self.0.clone()
}
}
fn spec_env(test_name: &str, values: &[(&str, &str)]) -> bool {
if std::env::var("A4AA_ENV_TEST_CHILD").ok().as_deref() == Some(test_name) {
return false;
}
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", test_name, "--test-threads=1"])
.env("A4AA_ENV_TEST_CHILD", test_name)
.envs(values.iter().copied())
.output()
.unwrap();
assert!(
output.status.success(),
"isolated environment test failed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed; 0 failed"));
true
}
#[tokio::test]
async fn default_policy_excludes_unlisted_credential_markers() {
if spec_env(
"env::tests::default_policy_excludes_unlisted_credential_markers",
&[("A4AA_TEST_INHERIT_MARKER", "hidden")],
) {
return;
}
let resolved = resolve(&EnvPolicy::default(), &BTreeMap::new(), &NoSecrets)
.await
.unwrap();
assert!(!resolved.contains_key("A4AA_TEST_INHERIT_MARKER"));
assert!(resolved.contains_key("PATH") || resolved.contains_key("HOME"));
}
#[tokio::test]
async fn sanitized_never_inherits_unlisted_host_variables() {
if spec_env(
"env::tests::sanitized_never_inherits_unlisted_host_variables",
&[("A4AA_TEST_SECRET_MARKER", "leak-check")],
) {
return;
}
let policy = EnvPolicy::Sanitized {
inherit: Vec::new(),
set: BTreeMap::new(),
};
let resolved = resolve(&policy, &BTreeMap::new(), &NoSecrets)
.await
.unwrap();
assert!(!resolved.contains_key("A4AA_TEST_SECRET_MARKER"));
}
#[tokio::test]
async fn inherit_all_drops_foreign_host_pid_binding() {
if spec_env(
"env::tests::inherit_all_drops_foreign_host_pid_binding",
&[
("WINDSURF_EXT_HOST_PID", "4242"),
("A4AA_TEST_HOST_MARKER", "kept"),
],
) {
return;
}
let resolved = resolve(
&EnvPolicy::InheritAll {
set: BTreeMap::new(),
},
&BTreeMap::new(),
&NoSecrets,
)
.await
.unwrap();
assert!(!resolved.contains_key("WINDSURF_EXT_HOST_PID"));
assert_eq!(resolved.get("A4AA_TEST_HOST_MARKER").unwrap(), "kept");
}
#[tokio::test]
async fn explicit_set_can_still_declare_host_pid_binding() {
if spec_env(
"env::tests::explicit_set_can_still_declare_host_pid_binding",
&[("WINDSURF_EXT_HOST_PID", "4242")],
) {
return;
}
let mut set = BTreeMap::new();
set.insert(
"WINDSURF_EXT_HOST_PID".to_owned(),
EnvValue::Literal("4242".into()),
);
let resolved = resolve(&EnvPolicy::InheritAll { set }, &BTreeMap::new(), &NoSecrets)
.await
.unwrap();
assert_eq!(resolved.get("WINDSURF_EXT_HOST_PID").unwrap(), "4242");
}
#[tokio::test]
async fn descriptor_temp_spelling_is_not_inherited() {
if spec_env(
"env::tests::descriptor_temp_spelling_is_not_inherited",
&[
("TMPDIR", "/dev/fd/300"),
("A4AA_TEST_TMPDIR_SENTINEL", "/var/tmp/x"),
],
) {
return;
}
let resolved = resolve(&EnvPolicy::default(), &BTreeMap::new(), &NoSecrets)
.await
.unwrap();
assert!(!resolved.contains_key("TMPDIR"));
}
#[tokio::test]
async fn explicit_set_still_injects_literal_tmpdir() {
if spec_env(
"env::tests::explicit_set_still_injects_literal_tmpdir",
&[("TMPDIR", "/dev/fd/300")],
) {
return;
}
let mut set = BTreeMap::new();
set.insert("TMPDIR".to_owned(), EnvValue::Literal("/dev/fd/300".into()));
let policy = EnvPolicy::Sanitized {
inherit: Vec::new(),
set,
};
let resolved = resolve(&policy, &BTreeMap::new(), &NoSecrets)
.await
.unwrap();
assert_eq!(resolved.get("TMPDIR").unwrap(), "/dev/fd/300");
}
#[tokio::test]
async fn unresolved_secret_fails_closed() {
let mut set = BTreeMap::new();
set.insert(
"AGENT_TOKEN".to_owned(),
EnvValue::Source(EnvSource::Secret {
secret: MISSING_REF.into(),
}),
);
let policy = EnvPolicy::Sanitized {
inherit: Vec::new(),
set,
};
let error = resolve(&policy, &BTreeMap::new(), &NoSecrets)
.await
.unwrap_err();
assert_eq!(error.code, "agent_env_source_unavailable");
}
#[tokio::test]
async fn resolved_secret_is_injected() {
let mut set = BTreeMap::new();
set.insert(
"AGENT_TOKEN".to_owned(),
EnvValue::Source(EnvSource::Secret {
secret: STORED_REF.into(),
}),
);
let policy = EnvPolicy::Sanitized {
inherit: Vec::new(),
set,
};
let resolved = resolve(
&policy,
&BTreeMap::new(),
&Resolver(Some(RESOLVED_VALUE.into())),
)
.await
.unwrap();
assert_eq!(resolved.get("AGENT_TOKEN").unwrap(), RESOLVED_VALUE);
}
}