From 0280d73c1e86365847c83dfe719f7eeba1c8f8a8 Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Mon, 24 Aug 2026 22:05:27 +0000 Subject: [PATCH] Broker credential aliases in child environments (#40484) ## What changed - Discover supported credentials inherited from the parent even when their canonical provider variable is filtered from the child environment. - Replace matching values and credentials embedded in longer child environment values with broker dummies, then restore only aliases that the broker virtualized. - Preserve provider and host bindings when selecting credentials for outbound requests, including GitHub Enterprise and custom OpenAI hosts. - Expose helpers for identifying provider and binding variables, plus an API for virtualizing credential-bearing text before it is persisted. ## Testing - Extend credential broker tests for filtered parent variables, exact and embedded aliases, text virtualization, user overrides, and host-bound GitHub and OpenAI credentials. GitOrigin-RevId: 55d673911900470aab4e206d0f4f5e62ed3611ae --- .../network-proxy/src/credential_broker.rs | 332 ++++++++++++++++-- .../src/credential_broker/providers.rs | 16 + .../src/credential_broker/providers/github.rs | 2 + .../src/credential_broker/providers/openai.rs | 1 + .../src/credential_broker_tests.rs | 233 +++++++++++- codex-rs/network-proxy/src/lib.rs | 2 + codex-rs/network-proxy/src/proxy.rs | 9 + codex-rs/network-proxy/src/runtime.rs | 13 + 8 files changed, 566 insertions(+), 42 deletions(-) diff --git a/codex-rs/network-proxy/src/credential_broker.rs b/codex-rs/network-proxy/src/credential_broker.rs index b58dc8b3d9..9dd5a4b0d8 100644 --- a/codex-rs/network-proxy/src/credential_broker.rs +++ b/codex-rs/network-proxy/src/credential_broker.rs @@ -9,6 +9,7 @@ use std::sync::RwLock; pub const CREDENTIAL_BROKER_ACTIVE_ENV_KEY: &str = "CODEX_NETWORK_PROXY_CREDENTIAL_BROKER_ACTIVE"; pub(crate) const BROKERED_CREDENTIALS_ENV_KEY: &str = "CODEX_NETWORK_PROXY_BROKERED_CREDENTIALS"; +const MIN_EMBEDDED_CREDENTIAL_LENGTH: usize = 16; #[derive(Clone)] pub(crate) struct CredentialBroker { @@ -20,6 +21,7 @@ struct CredentialBrokerState { enabled: bool, openai_api_host: Option, credentials: Vec, + credential_aliases: Vec, } struct CredentialRecord { @@ -30,6 +32,11 @@ struct CredentialRecord { dummy_value: String, } +struct CredentialAlias { + env_var: String, + dummy_value: String, +} + fn env_key_matches(candidate: &str, expected: &str) -> bool { if cfg!(windows) { candidate.eq_ignore_ascii_case(expected) @@ -78,6 +85,7 @@ impl CredentialBroker { if state.enabled != config.credential_broker { state.enabled = config.credential_broker; state.credentials.clear(); + state.credential_aliases.clear(); } if state.openai_api_host != config.credential_broker_openai_host { state @@ -89,6 +97,45 @@ impl CredentialBroker { } } + pub(crate) fn discover_parent_credentials( + &self, + parent_env: &HashMap, + child_env: &HashMap, + ) { + let mut state = self.write_state(); + if !state.enabled { + return; + } + + for provider in providers::credential_providers() { + for source in provider.sources() { + let Some(host_binding) = + (source.host_binding)(child_env, state.openai_api_host.as_deref()) + else { + continue; + }; + for env_var in source.env_vars { + let Some(real_value) = + brokerable_credential_value(parent_env, &state, env_var, provider) + .map(str::to_string) + else { + continue; + }; + if env_value(child_env, env_var) == Some(real_value.as_str()) { + continue; + } + if child_env.values().any(|value| { + value == &real_value + || real_value.len() >= MIN_EMBEDDED_CREDENTIAL_LENGTH + && value.contains(&real_value) + }) { + state.register(env_var, provider, host_binding.clone(), &real_value); + } + } + } + } + } + pub(crate) fn virtualize_child_env(&self, env: &mut HashMap) { let mut state = self.write_state(); if !state.enabled { @@ -100,45 +147,105 @@ impl CredentialBroker { for provider in providers::credential_providers() { for source in provider.sources() { - if let Some(host_binding) = + let Some(host_binding) = (source.host_binding)(env, state.openai_api_host.as_deref()) - { - for env_var in source.env_vars { - virtualize_env_var( - env, - &mut state, - env_var, - provider, - host_binding.clone(), - ); - } + else { + continue; + }; + for env_var in source.env_vars { + virtualize_env_var(env, &mut state, env_var, provider, host_binding.clone()); } } } + let credentials = prioritized_credentials(&state, env); + let mut credential_aliases = Vec::new(); + for (key, value) in env.iter_mut() { + let mut virtualized = false; + for credential in &credentials { + if value == &credential.real_value { + value.clone_from(&credential.dummy_value); + virtualized = true; + } else if credential.real_value.len() >= MIN_EMBEDDED_CREDENTIAL_LENGTH + && value.contains(&credential.real_value) + { + *value = value.replace(&credential.real_value, &credential.dummy_value); + virtualized = true; + } + } + if virtualized { + credential_aliases.push(CredentialAlias { + env_var: key.clone(), + dummy_value: value.clone(), + }); + } + } + for alias in credential_aliases { + if !state.credential_aliases.iter().any(|existing| { + env_key_matches(&existing.env_var, &alias.env_var) + && existing.dummy_value == alias.dummy_value + }) { + state.credential_aliases.push(alias); + } + } update_brokered_credentials_marker(&state, env); } pub(crate) fn restore_child_env( &self, env: &mut HashMap, - command: &mut [String], + _command: &mut [String], ) { let state = self.read_state(); if !state.enabled || env_value(env, CREDENTIAL_BROKER_ACTIVE_ENV_KEY) != Some("1") { return; } - for (key, value) in env { - if let Some(credential) = state.credentials.iter().find(|credential| { - env_key_matches(key, &credential.env_var) && value == &credential.dummy_value - }) { - for argument in command.iter_mut() { - if argument.contains(&credential.dummy_value) { - *argument = - argument.replace(&credential.dummy_value, &credential.real_value); - } + let credentials = state + .credentials + .iter() + .filter(|credential| { + env.iter().any(|(key, value)| { + !env_key_matches(key, CREDENTIAL_BROKER_ACTIVE_ENV_KEY) + && !env_key_matches(key, BROKERED_CREDENTIALS_ENV_KEY) + && (value == &credential.dummy_value + || credential.real_value.len() >= MIN_EMBEDDED_CREDENTIAL_LENGTH + && value.contains(&credential.dummy_value)) + }) + }) + .collect::>(); + for (key, value) in env.iter_mut() { + if env_key_matches(key, CREDENTIAL_BROKER_ACTIVE_ENV_KEY) + || env_key_matches(key, BROKERED_CREDENTIALS_ENV_KEY) + { + continue; + } + let canonical_credential = state + .credentials + .iter() + .any(|credential| env_key_matches(key, &credential.env_var)); + if !canonical_credential + && !state.credential_aliases.iter().any(|alias| { + env_key_matches(key, &alias.env_var) && value == &alias.dummy_value + }) + { + continue; + } + for credential in &credentials { + if canonical_credential + && !state.credentials.iter().any(|candidate| { + env_key_matches(key, &candidate.env_var) + && std::ptr::eq(candidate.provider, credential.provider) + && candidate.host_binding == credential.host_binding + && candidate.real_value == credential.real_value + }) + { + continue; + } + if value == &credential.dummy_value { + value.clone_from(&credential.real_value); + } else if credential.real_value.len() >= MIN_EMBEDDED_CREDENTIAL_LENGTH { + *value = value.replace(&credential.dummy_value, &credential.real_value); } - *value = credential.real_value.clone(); } } } @@ -153,6 +260,49 @@ impl CredentialBroker { .any(|credential| credential.matches_host(&normalized_host)) } + pub(crate) fn virtualize_text(&self, text: &mut String, env: &HashMap) -> bool { + let state = self.read_state(); + if !state.enabled { + return true; + } + + let allowed_keys = brokered_credential_dummy_env_keys(env); + let credentials = prioritized_credentials(&state, env); + let mut allowed = true; + for credential in &credentials { + let contains_real = text.as_str() == credential.real_value + || credential.real_value.len() >= MIN_EMBEDDED_CREDENTIAL_LENGTH + && text.contains(&credential.real_value); + let contains_dummy = text.as_str() == credential.dummy_value + || credential.dummy_value.len() >= MIN_EMBEDDED_CREDENTIAL_LENGTH + && text.contains(&credential.dummy_value); + if !contains_real && !contains_dummy { + continue; + } + + let replacement = credentials.iter().copied().find(|candidate| { + std::ptr::eq(candidate.provider, credential.provider) + && candidate.real_value == credential.real_value + && allowed_keys.iter().any(|key| { + env_key_matches(key, &candidate.env_var) + && env_value(env, key) == Some(candidate.dummy_value.as_str()) + }) + }); + if replacement.is_none() { + allowed = false; + } + let replacement = replacement.map_or("", |candidate| candidate.dummy_value.as_str()); + if contains_real { + *text = text.replace(&credential.real_value, replacement); + } + if contains_dummy && credential.dummy_value != replacement { + *text = text.replace(&credential.dummy_value, replacement); + } + } + + allowed + } + pub(crate) fn inject_request_headers(&self, host: &str, headers: &mut HeaderMap) { let normalized_host = normalize_host(host); let state = self.read_state(); @@ -160,12 +310,8 @@ impl CredentialBroker { return; } - let matching_credentials = state - .credentials - .iter() - .filter(|credential| credential.matches_host(&normalized_host)) - .collect::>(); - let Some((credential, header_value)) = select_credential(headers, &matching_credentials) + let Some((credential, header_value)) = + select_credential(headers, &normalized_host, &state.credentials) else { return; }; @@ -194,11 +340,32 @@ fn virtualize_env_var( provider: &'static providers::CredentialProvider, host_binding: providers::CredentialHostBinding, ) { - let Some(real_value) = brokerable_credential_value(env, state, env_var, provider) else { + let Some(real_value) = brokerable_credential_value(env, state, env_var, provider) + .map(str::to_string) + .or_else(|| { + let dummy = env_value(env, env_var)?; + state + .credentials + .iter() + .find(|credential| { + credential.dummy_value == dummy + && std::ptr::eq(credential.provider, provider) + && !env_key_matches(&credential.env_var, env_var) + && credential.host_binding == host_binding + && state.credentials.iter().any(|candidate| { + env_key_matches(&candidate.env_var, env_var) + && std::ptr::eq(candidate.provider, provider) + && candidate.host_binding == host_binding + && candidate.real_value == credential.real_value + }) + }) + .map(|credential| credential.real_value.clone()) + }) + else { return; }; - let dummy_value = state.register(env_var, provider, host_binding, real_value); + let dummy_value = state.register(env_var, provider, host_binding, &real_value); set_env_value(env, env_var, dummy_value); } @@ -261,21 +428,60 @@ impl CredentialRecord { } } +fn prioritized_credentials<'a>( + state: &'a CredentialBrokerState, + env: &HashMap, +) -> Vec<&'a CredentialRecord> { + let mut credentials = state.credentials.iter().collect::>(); + credentials.sort_unstable_by_key(|credential| { + let active = env_entry(env, &credential.env_var) + .is_some_and(|(_, value)| value == credential.dummy_value); + let has_matching_host_binding = credential.provider.sources().iter().any(|source| { + !source.binding_env_vars.is_empty() + && (source.host_binding)(env, state.openai_api_host.as_deref()) + .is_some_and(|binding| binding == credential.host_binding) + }); + ( + std::cmp::Reverse(credential.real_value.len()), + std::cmp::Reverse(active), + std::cmp::Reverse(has_matching_host_binding), + ) + }); + credentials +} + fn select_credential<'a>( headers: &HeaderMap, - matching_credentials: &[&'a CredentialRecord], + host: &str, + credentials: &'a [CredentialRecord], ) -> Option<(&'a CredentialRecord, rama_http::HeaderValue)> { - let mut translated_matches = matching_credentials + let mut translated_matches = credentials .iter() - .copied() + .filter(|credential| credential.matches_host(host)) .filter_map(|credential| { - credential - .provider - .translate_request_header(headers, &credential.dummy_value, &credential.real_value) + credentials + .iter() + .filter(|candidate| { + std::ptr::eq(candidate.provider, credential.provider) + && candidate.real_value == credential.real_value + && candidate.matches_host(host) + }) + .find_map(|candidate| { + credential.provider.translate_request_header( + headers, + &candidate.dummy_value, + &credential.real_value, + ) + }) .map(|header_value| (credential, header_value)) }); let matched = translated_matches.next()?; - translated_matches.next().is_none().then_some(matched) + translated_matches + .all(|(credential, _)| { + std::ptr::eq(credential.provider, matched.0.provider) + && credential.real_value == matched.0.real_value + }) + .then_some(matched) } fn update_brokered_credentials_marker( @@ -285,7 +491,14 @@ fn update_brokered_credentials_marker( let brokered = providers::credential_env_keys() .filter_map(|key| { let (actual_key, value) = env_entry(env, key)?; - state.is_dummy_value(value).then_some((actual_key, value)) + state + .credentials + .iter() + .any(|credential| { + env_key_matches(actual_key, &credential.env_var) + && value == credential.dummy_value + }) + .then_some((actual_key, value)) }) .collect::>(); match serde_json::to_string(&brokered) { @@ -317,15 +530,56 @@ pub fn brokered_credential_dummy_env_keys(env: &HashMap) -> Vec< (actual_value == dummy_value.as_str()).then(|| actual_key.to_string()) }) .collect::>(); - keys.sort_unstable(); + let context_bound = |key: &str| { + providers::credential_providers().any(|provider| { + provider.sources().iter().any(|source| { + source + .env_vars + .iter() + .any(|candidate| env_key_matches(key, candidate)) + && source + .binding_env_vars + .iter() + .any(|context| env_value(env, context).is_some()) + }) + }) + }; + keys.sort_unstable_by(|left, right| { + context_bound(right) + .cmp(&context_bound(left)) + .then_with(|| left.cmp(right)) + }); keys } +/// Returns environment keys used to bind currently brokered credentials to hosts. +pub fn brokered_credential_binding_env_keys( + env: &HashMap, +) -> impl Iterator { + let brokered_keys = brokered_credential_dummy_env_keys(env); + providers::credential_binding_env_keys(&brokered_keys) + .filter(|key| env_value(env, key).is_some()) + .collect::>() + .into_iter() +} + /// Returns environment keys used to bind registered credential providers to their destinations. pub fn credential_broker_provider_context_env_keys() -> impl Iterator { providers::credential_providers().flat_map(|provider| provider.context_env_vars.iter().copied()) } +/// Returns whether an environment key belongs to a supported credential provider. +pub fn is_credential_broker_provider_env_key(key: &str) -> bool { + providers::credential_providers().any(|provider| { + provider + .sources() + .iter() + .flat_map(|source| source.env_vars.iter().copied()) + .chain(provider.context_env_vars.iter().copied()) + .any(|candidate| env_key_matches(key, candidate)) + }) +} + /// Returns credential keys plus provider context keys already present in an environment with an /// active broker. pub fn brokered_credential_env_keys( diff --git a/codex-rs/network-proxy/src/credential_broker/providers.rs b/codex-rs/network-proxy/src/credential_broker/providers.rs index 4bc56952c1..88657f5ee3 100644 --- a/codex-rs/network-proxy/src/credential_broker/providers.rs +++ b/codex-rs/network-proxy/src/credential_broker/providers.rs @@ -36,6 +36,7 @@ pub(super) enum CredentialHostBinding { pub(super) struct CredentialSource { pub(super) env_vars: &'static [&'static str], + pub(super) binding_env_vars: &'static [&'static str], pub(super) host_binding: fn(&HashMap, Option<&str>) -> Option, } @@ -102,6 +103,21 @@ pub(super) fn credential_context_env_keys( .flat_map(|provider| provider.context_env_vars.iter().copied()) } +pub(super) fn credential_binding_env_keys( + brokered_keys: &[String], +) -> impl Iterator + '_ { + credential_providers() + .flat_map(CredentialProvider::sources) + .filter(move |source| { + source.env_vars.iter().any(|key| { + brokered_keys + .iter() + .any(|brokered_key| super::env_key_matches(brokered_key, key)) + }) + }) + .flat_map(|source| source.binding_env_vars.iter().copied()) +} + pub(super) fn credential_env_keys() -> impl Iterator { credential_providers() .flat_map(CredentialProvider::sources) diff --git a/codex-rs/network-proxy/src/credential_broker/providers/github.rs b/codex-rs/network-proxy/src/credential_broker/providers/github.rs index 523277ba29..e972ede28a 100644 --- a/codex-rs/network-proxy/src/credential_broker/providers/github.rs +++ b/codex-rs/network-proxy/src/credential_broker/providers/github.rs @@ -24,10 +24,12 @@ pub(super) static PROVIDER: CredentialProvider = CredentialProvider { sources: &[ CredentialSource { env_vars: GITHUB_CLOUD_TOKEN_ENV_VARS, + binding_env_vars: &[], host_binding: github_cloud_binding, }, CredentialSource { env_vars: GITHUB_ENTERPRISE_TOKEN_ENV_VARS, + binding_env_vars: &[GH_HOST_ENV_VAR], host_binding: github_enterprise_binding, }, ], diff --git a/codex-rs/network-proxy/src/credential_broker/providers/openai.rs b/codex-rs/network-proxy/src/credential_broker/providers/openai.rs index a7e14db1e4..716d3b3d4e 100644 --- a/codex-rs/network-proxy/src/credential_broker/providers/openai.rs +++ b/codex-rs/network-proxy/src/credential_broker/providers/openai.rs @@ -18,6 +18,7 @@ pub(super) static PROVIDER: CredentialProvider = CredentialProvider { context_env_vars: &[OPENAI_BASE_URL_ENV_VAR], sources: &[CredentialSource { env_vars: OPENAI_API_KEY_ENV_VARS, + binding_env_vars: &[OPENAI_BASE_URL_ENV_VAR], host_binding, }], reset_on_configuration_change: true, diff --git a/codex-rs/network-proxy/src/credential_broker_tests.rs b/codex-rs/network-proxy/src/credential_broker_tests.rs index fc89aa3a33..9806b1375f 100644 --- a/codex-rs/network-proxy/src/credential_broker_tests.rs +++ b/codex-rs/network-proxy/src/credential_broker_tests.rs @@ -51,10 +51,13 @@ fn virtualize_child_env_replaces_supported_credentials() { let broker = CredentialBroker::new(/*enabled*/ true); let github_token = "github_pat_11AA0bbCC_abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGH"; let openai_api_key = "sk-proj-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"; + let authorization = format!("Bearer {github_token}"); let mut env = env_map([ ("GH_TOKEN", github_token), + ("HOMEBREW_GITHUB_API_TOKEN", github_token), + ("AUTH_HEADER", authorization.as_str()), ("OPENAI_API_KEY", openai_api_key), - ("GH_ENTERPRISE_TOKEN", "ghp-enterprise-real"), + ("GH_ENTERPRISE_TOKEN", github_token), ]); broker.virtualize_child_env(&mut env); @@ -63,12 +66,37 @@ fn virtualize_child_env_replaces_supported_credentials() { let openai_dummy = env.get("OPENAI_API_KEY").expect("dummy OpenAI API key"); assert_credential_shape(github_token, github_dummy, "github_pat_"); assert_credential_shape(openai_api_key, openai_dummy, "sk-proj-"); + assert_eq!(env.get("HOMEBREW_GITHUB_API_TOKEN"), Some(github_dummy)); + assert_eq!(env.get("GH_ENTERPRISE_TOKEN"), Some(github_dummy)); + assert_eq!( + env.get("AUTH_HEADER"), + Some(&format!("Bearer {github_dummy}")) + ); + let mut persisted_credentials = format!("{github_token}\n{openai_api_key}"); + assert!(broker.virtualize_text(&mut persisted_credentials, &env)); + assert_eq!( + persisted_credentials, + format!("{github_dummy}\n{openai_dummy}") + ); + let mut filtered_env = env.clone(); + filtered_env.remove("OPENAI_API_KEY"); + let mut excluded_credentials = format!("{github_token}\n{openai_api_key}"); + assert!(!broker.virtualize_text(&mut excluded_credentials, &filtered_env)); + assert_eq!(excluded_credentials, format!("{github_dummy}\n")); + let mut excluded_dummies = format!("{github_dummy}\n{openai_dummy}"); + assert!(!broker.virtualize_text(&mut excluded_dummies, &filtered_env)); + assert_eq!(excluded_dummies, format!("{github_dummy}\n")); let mut command = vec![ format!("Authorization: Bearer {github_dummy}"), format!("Authorization: Bearer {openai_dummy}"), ]; + let github_dummy = github_dummy.clone(); let openai_dummy = openai_dummy.clone(); env.insert("OPENAI_API_KEY".to_string(), "sk-user-override".to_string()); + env.insert( + "GIT_CONFIG_VALUE_0".to_string(), + format!("Authorization: Bearer {github_dummy}"), + ); assert_eq!( brokered_credential_dummy_env_keys(&env), vec!["GH_TOKEN".to_string()] @@ -76,17 +104,36 @@ fn virtualize_child_env_replaces_supported_credentials() { broker.restore_child_env(&mut env, &mut command); assert_eq!(env.get("GH_TOKEN").map(String::as_str), Some(github_token)); + assert_eq!( + env.get("HOMEBREW_GITHUB_API_TOKEN").map(String::as_str), + Some(github_token) + ); + assert_eq!( + env.get("GH_ENTERPRISE_TOKEN").map(String::as_str), + Some(github_token) + ); + assert_eq!(env.get("AUTH_HEADER"), Some(&authorization)); assert_eq!( env.get("OPENAI_API_KEY").map(String::as_str), Some("sk-user-override") ); + assert_eq!( + env.get("GIT_CONFIG_VALUE_0"), + Some(&format!("Authorization: Bearer {github_dummy}")) + ); assert_eq!( command, vec![ - format!("Authorization: Bearer {github_token}"), + format!("Authorization: Bearer {github_dummy}"), format!("Authorization: Bearer {openai_dummy}"), ] ); + + env.insert("GH_TOKEN".to_string(), openai_dummy.clone()); + env.insert("OPENAI_API_KEY".to_string(), github_dummy.clone()); + broker.restore_child_env(&mut env, &mut []); + assert_eq!(env.get("GH_TOKEN"), Some(&openai_dummy)); + assert_eq!(env.get("OPENAI_API_KEY"), Some(&github_dummy)); } #[cfg(windows)] @@ -130,6 +177,119 @@ fn virtualize_child_env_preserves_live_dummy_mappings() { assert_eq!(authorization(&first_headers), Some("Bearer ghp-real-one")); assert_eq!(authorization(&second_headers), Some("Bearer ghp-real-two")); + + let mut alias_only = env_map([("HOMEBREW_GITHUB_API_TOKEN", "ghp-real-one")]); + broker.virtualize_child_env(&mut alias_only); + assert_eq!( + alias_only.get("HOMEBREW_GITHUB_API_TOKEN"), + Some(first_dummy) + ); + broker.restore_child_env(&mut alias_only, &mut []); + assert_eq!(alias_only["HOMEBREW_GITHUB_API_TOKEN"], "ghp-real-one"); + + let mut overridden = env_map([ + ("GH_TOKEN", "ghp-real-two"), + ("HOMEBREW_GITHUB_API_TOKEN", "ghp-real-one"), + ]); + broker.virtualize_child_env(&mut overridden); + assert_eq!(overridden.get("GH_TOKEN"), Some(second_dummy)); + assert_eq!( + overridden.get("HOMEBREW_GITHUB_API_TOKEN"), + Some(first_dummy) + ); + + let mut cloud_alias = env_map([ + ("GH_TOKEN", "ghp-real-one"), + ("GITHUB_TOKEN", "ghp-real-one"), + ]); + broker.virtualize_child_env(&mut cloud_alias); + cloud_alias.insert("GITHUB_TOKEN".to_string(), first_dummy.clone()); + broker.restore_child_env(&mut cloud_alias, &mut []); + assert_eq!(cloud_alias["GITHUB_TOKEN"], "ghp-real-one"); + + let mut distinct_credentials = env_map([ + ("GH_TOKEN", "ghp-primary-secret"), + ("GITHUB_TOKEN", "ghp-secondary-secret"), + ]); + broker.virtualize_child_env(&mut distinct_credentials); + let secondary_dummy = distinct_credentials["GITHUB_TOKEN"].clone(); + distinct_credentials.remove("GITHUB_TOKEN"); + distinct_credentials.insert("GH_TOKEN".to_string(), secondary_dummy.clone()); + broker.virtualize_child_env(&mut distinct_credentials); + broker.restore_child_env(&mut distinct_credentials, &mut []); + assert_eq!(distinct_credentials["GH_TOKEN"], secondary_dummy); +} + +#[test] +fn virtualize_child_env_replaces_aliases_of_filtered_parent_credentials() { + let broker = CredentialBroker::new(/*enabled*/ true); + let github_token = "ghp_abcdefghijklmnopqrstuvwxyz1234567890"; + let authorization_header = format!("Bearer {github_token}"); + let parent_env = env_map([ + ("GH_TOKEN", github_token), + ("HOMEBREW_GITHUB_API_TOKEN", github_token), + ]); + let mut child_env = env_map([ + ("HOMEBREW_GITHUB_API_TOKEN", github_token), + ("AUTH_HEADER", authorization_header.as_str()), + ]); + + broker.discover_parent_credentials(&parent_env, &child_env); + broker.virtualize_child_env(&mut child_env); + + let dummy = child_env["HOMEBREW_GITHUB_API_TOKEN"].clone(); + assert_ne!(dummy, github_token); + assert_eq!(child_env["AUTH_HEADER"], format!("Bearer {dummy}")); + assert!(!child_env.contains_key("GH_TOKEN")); + + let mut headers = headers_with_bearer(&dummy); + broker.inject_request_headers("api.github.com", &mut headers); + assert_eq!(authorization(&headers), Some(authorization_header.as_str())); + + broker.restore_child_env(&mut child_env, &mut []); + assert_eq!(child_env["HOMEBREW_GITHUB_API_TOKEN"], github_token); + assert_eq!(child_env["AUTH_HEADER"], authorization_header); + assert!(!child_env.contains_key("GH_TOKEN")); +} + +#[test] +fn virtualize_child_env_binds_filtered_enterprise_credentials_to_child_host() { + let github_token = "ghp_abcdefghijklmnopqrstuvwxyz1234567890"; + let authorization_header = format!("Bearer {github_token}"); + + for (parent_host, include_cloud_token) in [ + (None, false), + (Some("github.previous.example"), false), + (None, true), + ] { + let broker = CredentialBroker::new(/*enabled*/ true); + let mut parent_env = env_map([("GH_ENTERPRISE_TOKEN", github_token)]); + if let Some(parent_host) = parent_host { + parent_env.insert("GH_HOST".to_string(), parent_host.to_string()); + } + if include_cloud_token { + parent_env.insert("GH_TOKEN".to_string(), github_token.to_string()); + } + let mut child_env = env_map([ + ("GH_HOST", "github.current.example"), + ("AUTH_HEADER", authorization_header.as_str()), + ]); + + broker.discover_parent_credentials(&parent_env, &child_env); + broker.virtualize_child_env(&mut child_env); + + assert_ne!(child_env["AUTH_HEADER"], authorization_header); + let mut headers = headers_with_authorization(&child_env["AUTH_HEADER"]); + broker.inject_request_headers("github.current.example", &mut headers); + assert_eq!(authorization(&headers), Some(authorization_header.as_str())); + + let mut previous_headers = headers_with_authorization(&child_env["AUTH_HEADER"]); + broker.inject_request_headers("github.previous.example", &mut previous_headers); + assert_eq!( + authorization(&previous_headers), + Some(child_env["AUTH_HEADER"].as_str()) + ); + } } #[test] @@ -192,6 +352,12 @@ fn virtualize_child_env_preserves_unbound_enterprise_token() { assert_eq!(env["GH_ENTERPRISE_TOKEN"], "ghp-enterprise-real"); assert_eq!(headers, headers_with_bearer(inert_token)); assert!(!broker.host_requires_mitm("attacker.example")); + + env.insert("GH_HOST".to_string(), "github.example.com".to_string()); + broker.virtualize_child_env(&mut env); + let mut headers = headers_with_bearer(&env["GH_ENTERPRISE_TOKEN"]); + broker.inject_request_headers("github.example.com", &mut headers); + assert_eq!(authorization(&headers), Some("Bearer ghp-enterprise-real")); } #[test] @@ -294,6 +460,7 @@ fn openai_credentials_bind_only_to_default_and_configured_trusted_hosts() { ]); broker.virtualize_child_env(&mut env); assert!(brokered_credential_env_keys(&env).any(|key| key == "OPENAI_BASE_URL")); + assert!(brokered_credential_binding_env_keys(&env).any(|key| key == "OPENAI_BASE_URL")); let dummy = &env["OPENAI_API_KEY"]; for (host, expected_credential) in [ @@ -330,6 +497,7 @@ fn github_cloud_credentials_match_ghe_com_host_hint() { let broker = CredentialBroker::new(/*enabled*/ true); let mut env = env_map([("GH_HOST", "astemu.ghe.com"), ("GH_TOKEN", "ghp-real")]); broker.virtualize_child_env(&mut env); + assert!(!brokered_credential_binding_env_keys(&env).any(|key| key == "GH_HOST")); let github_token = env.get("GH_TOKEN").expect("dummy GitHub token"); let mut headers = headers_with_bearer(github_token); @@ -362,18 +530,77 @@ fn github_enterprise_credentials_bind_to_gh_host() { let broker = CredentialBroker::new(/*enabled*/ true); let mut env = env_map([ ("GH_HOST", "github.example.com"), + ("GH_TOKEN", "ghp-enterprise-real"), ("GH_ENTERPRISE_TOKEN", "ghp-enterprise-real"), + ("AUTH_HEADER", "Bearer ghp-enterprise-real"), ]); broker.virtualize_child_env(&mut env); + let github_dummy = env["GH_TOKEN"].clone(); assert!(brokered_credential_env_keys(&env).any(|key| key == "GH_HOST")); + assert!(brokered_credential_binding_env_keys(&env).any(|key| key == "GH_HOST")); let github_token = env .get("GH_ENTERPRISE_TOKEN") .expect("dummy GitHub enterprise token"); + assert_ne!(github_token, &github_dummy); let mut headers = headers_with_bearer(github_token); broker.inject_request_headers("github.example.com", &mut headers); assert_eq!(authorization(&headers), Some("Bearer ghp-enterprise-real")); + assert_eq!(env["AUTH_HEADER"], format!("Bearer {github_token}")); + let mut alias_headers = headers_with_authorization(&env["AUTH_HEADER"]); + broker.inject_request_headers("github.example.com", &mut alias_headers); + assert_eq!( + authorization(&alias_headers), + Some("Bearer ghp-enterprise-real") + ); + let mut persisted_alias = "Bearer ghp-enterprise-real".to_string(); + assert!(broker.virtualize_text(&mut persisted_alias, &env)); + assert_eq!(persisted_alias, format!("Bearer {github_token}")); + assert_eq!( + brokered_credential_dummy_env_keys(&env).first(), + Some(&"GH_ENTERPRISE_TOKEN".to_string()) + ); + let mut cloud_headers = headers_with_bearer(&github_dummy); + broker.inject_request_headers("github.example.com", &mut cloud_headers); + assert_eq!(cloud_headers, headers_with_bearer(&github_dummy)); + let mut enterprise_headers = headers_with_bearer(github_token); + broker.inject_request_headers("api.github.com", &mut enterprise_headers); + assert_eq!(enterprise_headers, headers_with_bearer(github_token)); + let mut cloud_only = env_map([ + ("GH_HOST", "github.example.com"), + ("GH_TOKEN", "ghp-enterprise-real"), + ("AUTH_HEADER", "Bearer ghp-enterprise-real"), + ]); + broker.virtualize_child_env(&mut cloud_only); + assert_eq!(cloud_only["AUTH_HEADER"], format!("Bearer {github_dummy}")); assert!(broker.host_requires_mitm("github.example.com")); - assert!(!broker.host_requires_mitm("api.github.com")); + assert!(broker.host_requires_mitm("api.github.com")); + + env.insert("GH_HOST".to_string(), "attacker.example".to_string()); + env.insert("GH_ENTERPRISE_TOKEN".to_string(), github_dummy.clone()); + broker.virtualize_child_env(&mut env); + let mut attacker_headers = headers_with_bearer(&github_dummy); + broker.inject_request_headers("attacker.example", &mut attacker_headers); + assert_eq!(attacker_headers, headers_with_bearer(&github_dummy)); + assert!(!broker.host_requires_mitm("attacker.example")); + + let mut alternate_enterprise_key = env_map([ + ("GH_HOST", "github.alternate.example"), + ("GH_TOKEN", "ghp-alternate-real"), + ("GITHUB_ENTERPRISE_TOKEN", "ghp-alternate-real"), + ("AUTH_HEADER", "Bearer ghp-alternate-real"), + ]); + broker.virtualize_child_env(&mut alternate_enterprise_key); + assert_eq!( + alternate_enterprise_key["AUTH_HEADER"], + format!( + "Bearer {}", + alternate_enterprise_key["GITHUB_ENTERPRISE_TOKEN"] + ) + ); + assert_eq!( + brokered_credential_dummy_env_keys(&alternate_enterprise_key).first(), + Some(&"GITHUB_ENTERPRISE_TOKEN".to_string()) + ); } diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index aaed6a2865..060ee0cdd6 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -41,9 +41,11 @@ pub use config::NetworkUnixSocketPermissions; pub use config::host_and_port_from_network_addr; pub use config::managed_proxy_ports; pub use credential_broker::CREDENTIAL_BROKER_ACTIVE_ENV_KEY; +pub use credential_broker::brokered_credential_binding_env_keys; pub use credential_broker::brokered_credential_dummy_env_keys; pub use credential_broker::brokered_credential_env_keys; pub use credential_broker::credential_broker_provider_context_env_keys; +pub use credential_broker::is_credential_broker_provider_env_key; pub use environment_policy::EnvironmentNetworkPolicy; pub use mitm_hook::InjectedHeaderConfig; pub use mitm_hook::MitmHookActionsConfig; diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index c723686fed..8172645e13 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -1109,6 +1109,15 @@ impl NetworkProxy { self.state.restore_child_credentials(env, command); } + /// Replaces allowed credentials and removes credentials excluded from the environment. + pub fn virtualize_brokered_text( + &self, + text: &mut String, + env: &HashMap, + ) -> bool { + self.state.virtualize_brokered_text(text, env) + } + pub fn apply_to_env_for_environment( &self, env: &mut HashMap, diff --git a/codex-rs/network-proxy/src/runtime.rs b/codex-rs/network-proxy/src/runtime.rs index 1a449a4438..1889f827a1 100644 --- a/codex-rs/network-proxy/src/runtime.rs +++ b/codex-rs/network-proxy/src/runtime.rs @@ -427,6 +427,11 @@ impl NetworkProxyState { } pub fn virtualize_child_credentials(&self, env: &mut HashMap) { + let parent_env = std::env::vars_os() + .filter_map(|(key, value)| Some((key.into_string().ok()?, value.into_string().ok()?))) + .collect(); + self.credential_broker + .discover_parent_credentials(&parent_env, env); self.credential_broker.virtualize_child_env(env); } @@ -438,6 +443,14 @@ impl NetworkProxyState { self.credential_broker.restore_child_env(env, command); } + pub(crate) fn virtualize_brokered_text( + &self, + text: &mut String, + env: &HashMap, + ) -> bool { + self.credential_broker.virtualize_text(text, env) + } + pub fn inject_request_credentials(&self, host: &str, headers: &mut rama_http::HeaderMap) { self.credential_broker.inject_request_headers(host, headers); }