Add credential brokering to network proxy feature config (#40466)

## What changed

- Add `features.network_proxy.credential_broker` and preserve structured network proxy settings when toggling the feature through config APIs.
- Protect broker enablement and provider context variables from project config, while carrying trusted context into filtered shell environments.
- Propagate brokered credentials through shell snapshots and restore real credentials for unsandboxed escalated commands.
- Normalize broker context environment keys case-insensitively on Windows and avoid conflicting remote managed-network enforcement when no proxy launch configuration is available.

## Testing

- Cover config layering, editing, project sanitization, Windows environment handling, shell snapshots, escalated execution, and end-to-end GitHub credential brokering in the zsh-fork runtime.

GitOrigin-RevId: 500e51e804e1988022052ccfe00b8fac5e495f4f
This commit is contained in:
Winston Howes
2026-08-24 19:52:27 +00:00
committed by copyberry
parent c2904b9c5a
commit c3d5e4418f
27 changed files with 1000 additions and 92 deletions
+8 -2
View File
@@ -1,5 +1,5 @@
use crate::ConfigLayerMetadata;
use crate::merge::is_multi_agent_v2_feature_path;
use crate::merge::is_structured_feature_path;
use serde_json::Value as JsonValue;
use sha2::Digest;
use sha2::Sha256;
@@ -29,7 +29,13 @@ pub(super) fn record_origins(
}
_ => {
if !path.is_empty() {
if matches!(value, TomlValue::Boolean(_)) && is_multi_agent_v2_feature_path(path) {
if matches!(value, TomlValue::Boolean(_)) && is_structured_feature_path(path) {
if path
.last()
.is_some_and(|feature| feature == "network_proxy")
{
origins.insert(path.join("."), meta.clone());
}
path.push("enabled".to_string());
origins.insert(path.join("."), meta.clone());
path.pop();
+1
View File
@@ -154,6 +154,7 @@ pub use mcp_types::McpServerToolConfig;
pub use mcp_types::McpServerTransportConfig;
pub use mcp_types::RawMcpServerConfig;
pub use merge::ShellEnvironmentPolicyFilterRepresentation;
pub use merge::is_structured_feature_path;
pub use merge::merge_toml_values;
pub use merge::shell_environment_filter_entry;
pub use overrides::build_cli_overrides_layer;
+2
View File
@@ -1,3 +1,4 @@
use super::credential_broker_trusted_config;
use super::discover_project_layers;
use super::layer_io;
use super::load_config_toml_for_required_layer_raw;
@@ -134,6 +135,7 @@ pub(super) async fn load_local_config_layers_with_overrides(
let trust_context = project_trust_context(
fs,
&discovery_config,
&credential_broker_trusted_config(&discovery_config, &[], &loaded_managed),
cwd,
&project_root_markers,
codex_home.as_path(),
+172 -5
View File
@@ -38,7 +38,9 @@ use crate::thread_config::ThreadConfigContext;
use crate::thread_config::ThreadConfigLoader;
use codex_file_system::ExecutorFileSystem;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_network_proxy::credential_broker_provider_context_env_keys;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::EnvironmentVariablePattern;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::protocol::AskForApproval;
@@ -47,6 +49,7 @@ use codex_utils_absolute_path::AbsolutePathBufGuard;
use codex_utils_path_uri::PathUri;
use dunce::canonicalize as normalize_path;
use serde::Deserialize;
use std::collections::HashMap;
use std::io;
use std::path::Path;
#[cfg(windows)]
@@ -389,6 +392,11 @@ pub async fn load_config_layers_state(
let project_trust_context = match project_trust_context(
fs,
&merged_so_far,
&credential_broker_trusted_config(
&merged_so_far,
&thread_config_layers,
&loaded_config_layers,
),
&cwd,
&project_root_markers,
codex_home,
@@ -996,6 +1004,8 @@ struct ProjectTrustContext {
repo_root_lookup_keys: Option<Vec<String>>,
projects_trust: std::collections::HashMap<String, TrustLevel>,
user_config_file: AbsolutePathBuf,
credential_broker_configured: bool,
credential_broker_binding_env: HashMap<String, String>,
}
#[derive(Deserialize)]
@@ -1111,7 +1121,11 @@ fn project_layer_entry(
entry.with_hooks_config_folder_override(hooks_config_folder_override)
}
fn sanitize_project_config(config: &mut TomlValue) -> Vec<String> {
fn sanitize_project_config(
config: &mut TomlValue,
credential_broker_configured: bool,
trusted_binding_env: &HashMap<String, String>,
) -> Vec<String> {
let Some(table) = config.as_table_mut() else {
return Vec::new();
};
@@ -1122,10 +1136,99 @@ fn sanitize_project_config(config: &mut TomlValue) -> Vec<String> {
ignored_keys.push((*key).to_string());
}
}
if let Some(features) = table.get_mut("features").and_then(TomlValue::as_table_mut)
&& features.remove("respect_system_proxy").is_some()
if let Some(features) = table.get_mut("features").and_then(TomlValue::as_table_mut) {
if features.remove("respect_system_proxy").is_some() {
ignored_keys.push("features.respect_system_proxy".to_string());
}
if credential_broker_configured
&& features
.get("network_proxy")
.is_some_and(|network_proxy| network_proxy.as_bool().is_some())
{
features.remove("network_proxy");
ignored_keys.push("features.network_proxy".to_string());
} else if let Some(network_proxy) = features
.get_mut("network_proxy")
.and_then(TomlValue::as_table_mut)
{
if network_proxy.remove("credential_broker").is_some() {
ignored_keys.push("features.network_proxy.credential_broker".to_string());
}
if credential_broker_configured && network_proxy.remove("enabled").is_some() {
ignored_keys.push("features.network_proxy.enabled".to_string());
}
}
}
if credential_broker_configured
&& let Some(policy) = table
.get_mut("shell_environment_policy")
.and_then(TomlValue::as_table_mut)
{
ignored_keys.push("features.respect_system_proxy".to_string());
let binding_keys = credential_broker_provider_context_env_keys().collect::<Vec<_>>();
if let Some(overrides) = policy.get_mut("set").and_then(TomlValue::as_table_mut) {
overrides.retain(|key, _| {
if binding_keys
.iter()
.any(|binding_key| key.eq_ignore_ascii_case(binding_key))
{
ignored_keys.push(format!("shell_environment_policy.set.{key}"));
false
} else {
true
}
});
}
if let Some(include_only) = policy
.get_mut("include_only")
.and_then(TomlValue::as_array_mut)
&& !include_only.is_empty()
{
for binding_key in &binding_keys {
if !include_only.iter().any(|pattern| {
pattern.as_str().is_some_and(|pattern| {
EnvironmentVariablePattern::new_case_insensitive(pattern)
.matches(binding_key)
})
}) {
include_only.push(TomlValue::String(binding_key.to_string()));
}
}
}
if let Some(filters) = policy.get_mut("filters").and_then(TomlValue::as_table_mut)
&& filters
.values()
.any(|action| action.as_str() == Some("include"))
{
for binding_key in &binding_keys {
if !filters.iter().any(|(pattern, action)| {
action.as_str() == Some("include")
&& EnvironmentVariablePattern::new_case_insensitive(pattern)
.matches(binding_key)
}) {
filters.insert(
binding_key.to_string(),
TomlValue::String("include".to_string()),
);
}
}
}
if !trusted_binding_env.is_empty()
&& ["inherit", "exclude", "include_only", "filters"]
.iter()
.any(|key| policy.contains_key(*key))
{
let overrides = policy
.entry("set".to_string())
.or_insert_with(|| TomlValue::Table(toml::map::Map::new()));
if let Some(overrides) = overrides.as_table_mut() {
for (key, value) in trusted_binding_env {
overrides.insert(key.clone(), TomlValue::String(value.clone()));
}
}
}
}
// Repository contents must not turn an ordinary key into a permission increase.
if let Some(chat) = table
@@ -1164,6 +1267,7 @@ fn project_ignored_config_keys_warning(
async fn project_trust_context(
fs: &dyn ExecutorFileSystem,
merged_config: &TomlValue,
trusted_broker_config: &TomlValue,
cwd: &AbsolutePathBuf,
project_root_markers: &[String],
config_base_dir: &Path,
@@ -1198,6 +1302,45 @@ async fn project_trust_context(
.into_iter()
.filter_map(|(key, project)| project.trust_level.map(|trust_level| (key, trust_level)))
.collect();
let credential_broker_configured = trusted_broker_config
.get("features")
.and_then(|features| features.get("network_proxy"))
.and_then(TomlValue::as_table)
.is_some_and(|network_proxy| {
network_proxy.get("credential_broker") == Some(&TomlValue::Boolean(true))
});
let credential_broker_binding_env = if credential_broker_configured {
let trusted_policy_overrides = trusted_broker_config
.get("shell_environment_policy")
.and_then(|policy| policy.get("set"))
.and_then(TomlValue::as_table);
credential_broker_provider_context_env_keys()
.filter_map(|key| {
let trusted_override = trusted_policy_overrides
.and_then(|overrides| {
overrides.iter().find(|(candidate, _)| {
if cfg!(windows) {
candidate.eq_ignore_ascii_case(key)
} else {
candidate.as_str() == key
}
})
})
.and_then(|(name, value)| {
value
.as_str()
.map(|value| (name.clone(), value.to_string()))
});
trusted_override.or_else(|| {
std::env::var(key)
.ok()
.map(|value| (key.to_string(), value))
})
})
.collect()
} else {
HashMap::new()
};
Ok(ProjectTrustContext {
project_root,
@@ -1209,9 +1352,29 @@ async fn project_trust_context(
repo_root_lookup_keys,
projects_trust,
user_config_file: user_config_file.clone(),
credential_broker_configured,
credential_broker_binding_env,
})
}
fn credential_broker_trusted_config(
merged_config: &TomlValue,
thread_config_layers: &[ConfigLayerEntry],
loaded_config_layers: &LoadedConfigLayers,
) -> TomlValue {
let mut trusted_config = merged_config.clone();
for layer in thread_config_layers {
merge_toml_values(&mut trusted_config, &layer.config);
}
if let Some(config) = loaded_config_layers.managed_config.as_ref() {
merge_toml_values(&mut trusted_config, &config.managed_config);
}
if let Some(config) = loaded_config_layers.managed_config_from_mdm.as_ref() {
merge_toml_values(&mut trusted_config, &config.managed_config);
}
trusted_config
}
/// Canonicalize the path and convert it to a string to be used as a key in the
/// projects trust map. On Windows, strips UNC, when possible, to try to ensure
/// that different paths that point to the same location have the same key.
@@ -1556,7 +1719,11 @@ async fn discover_project_layers(
dot_codex_abs.as_path(),
)?;
}
let ignored_project_config_keys = sanitize_project_config(&mut config);
let ignored_project_config_keys = sanitize_project_config(
&mut config,
trust_context.credential_broker_configured,
&trust_context.credential_broker_binding_env,
);
if disabled_reason.is_none() && !ignored_project_config_keys.is_empty() {
startup_warnings.push(project_ignored_config_keys_warning(
&dot_codex_abs,
+203 -1
View File
@@ -19,13 +19,172 @@ use tempfile::tempdir;
pub(super) struct TestFileSystem;
#[test]
fn project_config_cannot_override_configured_credential_broker_hosts() {
let mut config: TomlValue = toml::from_str(
"[shell_environment_policy.set]\n\
GH_HOST = 'attacker.example'\n\
OPENAI_BASE_URL = 'https://attacker.example/v1'",
)
.expect("valid project config");
let ignored = sanitize_project_config(
&mut config,
/*credential_broker_configured*/ true,
&HashMap::new(),
);
assert_eq!(
ignored,
[
"shell_environment_policy.set.GH_HOST",
"shell_environment_policy.set.OPENAI_BASE_URL",
]
);
assert_eq!(
config,
toml::from_str::<TomlValue>("[shell_environment_policy.set]")
.expect("valid expected config")
);
}
#[test]
fn project_config_cannot_change_configured_credential_broker_state() {
for project_config in [
"[features]\nnetwork_proxy = true",
"[features]\nnetwork_proxy = false",
"[features.network_proxy]\nenabled = true",
"[features.network_proxy]\nenabled = false",
] {
let mut config: TomlValue = toml::from_str(project_config).expect("valid project config");
let ignored = sanitize_project_config(
&mut config,
/*credential_broker_configured*/ true,
&HashMap::new(),
);
assert_eq!(ignored.len(), 1);
assert!(
config
.get("features")
.and_then(|features| features.get("network_proxy"))
.is_none_or(|network_proxy| {
network_proxy
.as_table()
.is_some_and(|network_proxy| !network_proxy.contains_key("enabled"))
})
);
}
}
#[test]
fn project_environment_filters_preserve_credential_host_bindings() {
for (project_config, expected_policy) in [
(
"[shell_environment_policy]\ninclude_only = ['GH_ENTERPRISE_TOKEN']",
"[shell_environment_policy]\ninclude_only = ['GH_ENTERPRISE_TOKEN', 'GH_HOST', 'OPENAI_BASE_URL']",
),
(
"[shell_environment_policy]\nexclude = ['*HOST*', '*BASE_URL*', 'OTHER']",
"[shell_environment_policy]\nexclude = ['*HOST*', '*BASE_URL*', 'OTHER']",
),
(
"[shell_environment_policy.filters]\nGH_ENTERPRISE_TOKEN = 'include'\n'*HOST*' = 'exclude'",
"[shell_environment_policy.filters]\nGH_ENTERPRISE_TOKEN = 'include'\n'*HOST*' = 'exclude'\nGH_HOST = 'include'\nOPENAI_BASE_URL = 'include'",
),
(
"[shell_environment_policy.filters]\n'*HOST*' = 'exclude'\nOTHER = 'exclude'",
"[shell_environment_policy.filters]\n'*HOST*' = 'exclude'\nOTHER = 'exclude'",
),
(
"[shell_environment_policy]\nexclude = ['*']",
"[shell_environment_policy]\nexclude = ['*']",
),
] {
let mut config: TomlValue = toml::from_str(project_config).expect("valid project config");
assert!(
sanitize_project_config(
&mut config,
/*credential_broker_configured*/ true,
&HashMap::new(),
)
.is_empty()
);
assert_eq!(
config,
toml::from_str::<TomlValue>(expected_policy).expect("valid expected policy")
);
}
}
#[test]
fn project_environment_filters_preserve_only_trusted_credential_host_bindings() {
let host = "github.enterprise.example";
let token = "ghp_enterprise_secret";
let trusted_binding_env = HashMap::from([("GH_HOST".to_string(), host.to_string())]);
for project_policy in [
"inherit = 'none'",
"inherit = 'core'",
"exclude = ['*']",
"filters = { '*' = 'exclude' }",
"include_only = ['GH_ENTERPRISE_TOKEN']",
] {
let mut project: TomlValue =
toml::from_str(&format!("[shell_environment_policy]\n{project_policy}"))
.expect("valid project config");
sanitize_project_config(
&mut project,
/*credential_broker_configured*/ true,
&trusted_binding_env,
);
let mut merged: TomlValue = toml::from_str(&format!(
"[shell_environment_policy.set]\nGH_ENTERPRISE_TOKEN = '{token}'"
))
.expect("valid user config");
merge_toml_values(&mut merged, &project);
let policy: crate::shell_environment_policy::ShellEnvironmentPolicyToml = merged
.get("shell_environment_policy")
.expect("shell environment policy")
.clone()
.try_into()
.expect("valid shell environment policy");
let actual = codex_protocol::shell_environment::populate_env(
[
("GH_HOST", host),
("AWS_SECRET_ACCESS_KEY", "unrelated_secret"),
]
.into_iter()
.map(|(key, value)| (key.to_string(), value.to_string())),
&policy.into(),
/*thread_id*/ None,
);
assert_eq!(
actual,
[("GH_HOST", host), ("GH_ENTERPRISE_TOKEN", token)]
.into_iter()
.map(|(key, value)| (key.to_string(), value.to_string()))
.collect::<HashMap<_, _>>(),
"project policy: {project_policy}"
);
}
}
#[test]
fn project_config_cannot_bind_permission_shortcuts() {
let safe = "[tui.keymap.chat]\nincrease_reasoning_effort = 'f9'\n";
for key in ["previous_permission_mode", "next_permission_mode"] {
let mut config = toml::from_str(&format!("{safe}{key} = 'page-down'")).unwrap();
assert_eq!(
sanitize_project_config(&mut config),
sanitize_project_config(
&mut config,
/*credential_broker_configured*/ false,
&HashMap::new(),
),
[format!("tui.keymap.chat.{key}")]
);
assert_eq!(config, toml::from_str::<TomlValue>(safe).unwrap());
@@ -746,6 +905,49 @@ async fn local_layers_keep_raw_paths_order_and_legacy_requirements() {
)
);
#[cfg(windows)]
{
std::fs::write(
&system_file,
"model_instructions_file='./system.md'\n\
[shell_environment_policy.set]\nGH_HOST='github.stale.example'\n",
)
.expect("write stale system GitHub host");
std::fs::write(
&user_file,
format!(
"{}\n[features.network_proxy]\nenabled=true\ncredential_broker=true\n\
[shell_environment_policy.set]\ngh_host='github.trusted.example'\n",
user_config("trusted")
),
)
.expect("write lowercase trusted GitHub host");
std::fs::write(
dot_codex.join(CONFIG_TOML_FILE),
"[shell_environment_policy]\ninherit='none'\n",
)
.expect("write project environment policy");
let layers = local::load_local_config_layers_with_overrides(
&TestFileSystem,
&codex_home,
&cwd,
&overrides,
)
.await
.expect("load lowercase trusted GitHub host");
let binding = layers.config.layers[2]
.toml
.get("shell_environment_policy")
.and_then(|policy| policy.get("set"))
.and_then(TomlValue::as_table)
.expect("trusted binding preserved");
assert_eq!(
binding.get("gh_host").and_then(TomlValue::as_str),
Some("github.trusted.example")
);
assert!(!binding.contains_key("GH_HOST"));
}
std::fs::write(&user_file, user_config("untrusted")).expect("write user config");
let layers = local::load_local_config_layers_with_overrides(
&TestFileSystem,
+23 -13
View File
@@ -1,5 +1,6 @@
use crate::key_aliases::normalize_key_aliases;
use crate::key_aliases::normalized_with_key_aliases;
use codex_network_proxy::credential_broker_provider_context_env_keys;
use codex_network_proxy::normalize_host;
use toml::Value as TomlValue;
@@ -58,24 +59,21 @@ pub fn merge_toml_values(base: &mut TomlValue, overlay: &TomlValue) {
merge_toml_values_at_path(base, overlay, &mut Vec::new());
}
pub(crate) fn is_multi_agent_v2_feature_path<S: AsRef<str>>(path: &[S]) -> bool {
match path {
[features, feature] => {
features.as_ref() == "features" && feature.as_ref() == "multi_agent_v2"
}
[profiles, _, features, feature] => {
profiles.as_ref() == "profiles"
&& features.as_ref() == "features"
&& feature.as_ref() == "multi_agent_v2"
}
_ => false,
}
pub fn is_structured_feature_path<S: AsRef<str>>(path: &[S]) -> bool {
let (features, feature) = match path {
[profiles, _, features, feature] if profiles.as_ref() == "profiles" => (features, feature),
[features, feature] => (features, feature),
_ => return false,
};
features.as_ref() == "features"
&& matches!(feature.as_ref(), "multi_agent_v2" | "network_proxy")
}
fn merge_toml_values_at_path(base: &mut TomlValue, overlay: &TomlValue, path: &mut Vec<String>) {
replace_shell_environment_policy_filter_representation(base, overlay, path);
if is_multi_agent_v2_feature_path(path) {
if is_structured_feature_path(path) {
if let TomlValue::Boolean(enabled) = base
&& overlay.is_table()
{
@@ -105,6 +103,18 @@ fn merge_toml_values_at_path(base: &mut TomlValue, overlay: &TomlValue, path: &m
normalize_case_insensitive_keys(base_table);
normalize_case_insensitive_keys(&mut overlay_table);
}
if cfg!(windows)
&& matches!(path.as_slice(), [policy, field] if policy == "shell_environment_policy" && field == "set")
{
for key in overlay_table.keys().filter(|key| {
credential_broker_provider_context_env_keys()
.any(|binding_key| key.eq_ignore_ascii_case(binding_key))
}) {
base_table.retain(|candidate, _| {
candidate == key || !candidate.eq_ignore_ascii_case(key)
});
}
}
for (key, value) in overlay_table {
path.push(key.clone());
+25
View File
@@ -255,6 +255,31 @@ fn multi_agent_v2_cli_overrides_preserve_boolean_and_nested_configuration() {
}
}
#[test]
fn network_proxy_feature_overrides_preserve_credential_broker_configuration() {
let enabled = (
"features.network_proxy".to_string(),
TomlValue::Boolean(true),
);
let broker = (
"features.network_proxy.credential_broker".to_string(),
TomlValue::Boolean(true),
);
let expected =
parse_toml("[features.network_proxy]\nenabled = true\ncredential_broker = true\n");
for overrides in [vec![enabled.clone(), broker.clone()], vec![broker, enabled]] {
assert_eq!(crate::build_cli_overrides_layer(&overrides), expected);
}
let mut base = parse_toml("[features]\nnetwork_proxy = true\n");
merge_toml_values(
&mut base,
&parse_toml("[features.network_proxy]\ncredential_broker = true\n"),
);
assert_eq!(base, expected);
}
/// Repeated opaque desktop overrides continue to replace their previous value.
#[test]
fn multi_agent_v2_cli_compatibility_excludes_opaque_desktop_paths() {
+3 -3
View File
@@ -1,4 +1,4 @@
use crate::merge::is_multi_agent_v2_feature_path;
use crate::merge::is_structured_feature_path;
use crate::merge::merge_toml_values;
use toml::Value as TomlValue;
@@ -29,7 +29,7 @@ fn apply_toml_override(root: &mut TomlValue, path: &str, value: TomlValue) {
if is_last {
match current {
TomlValue::Table(table) => {
if is_multi_agent_v2_feature_path(&traversed_segments)
if is_structured_feature_path(&traversed_segments)
&& let Some(existing) = table.get_mut(segment)
{
match (&mut *existing, &value) {
@@ -77,7 +77,7 @@ fn apply_toml_override(root: &mut TomlValue, path: &str, value: TomlValue) {
current = table
.entry(segment.to_string())
.or_insert_with(|| TomlValue::Table(Table::new()));
if is_multi_agent_v2_feature_path(&traversed_segments)
if is_structured_feature_path(&traversed_segments)
&& let TomlValue::Boolean(enabled) = current
{
*current = TomlValue::Table(Table::from_iter([(