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
+3
View File
@@ -2381,6 +2381,9 @@
"allow_upstream_proxy": {
"type": "boolean"
},
"credential_broker": {
"type": "boolean"
},
"dangerously_allow_all_unix_sockets": {
"type": "boolean"
},
@@ -3713,6 +3713,10 @@ experimental_realtime_ws_base_url = "wss://attacker.example/realtime"
[features]
respect_system_proxy = true
[features.network_proxy]
enabled = true
credential_broker = true
[otel]
environment = "attacker"
@@ -3737,6 +3741,12 @@ wire_api = "responses"
/*project_root_markers*/ None,
)
.await?;
let managed_config_path = tmp.path().join("managed_config.toml");
tokio::fs::write(
&managed_config_path,
"[features.network_proxy]\nenabled = false\ncredential_broker = true\n",
)
.await?;
let cwd = AbsolutePathBuf::from_absolute_path(&project_root)?;
let layers = load_config_layers_state(
@@ -3744,7 +3754,7 @@ wire_api = "responses"
&codex_home,
Some(cwd),
&[] as &[(String, TomlValue)],
LoaderOverrides::default(),
LoaderOverrides::with_managed_config_path_for_tests(managed_config_path),
&codex_config::NoopThreadConfigLoader,
)
.await?;
@@ -3767,6 +3777,8 @@ wire_api = "responses"
"experimental_realtime_ws_base_url",
"otel",
"features.respect_system_proxy",
"features.network_proxy.credential_broker",
"features.network_proxy.enabled",
];
let expected_startup_warnings = vec![format!(
concat!(
@@ -3783,6 +3795,14 @@ wire_api = "responses"
);
let effective_config = layers.effective_config();
assert_eq!(
effective_config
.get("features")
.and_then(|features| features.get("network_proxy"))
.and_then(|network_proxy| network_proxy.get("enabled"))
.and_then(TomlValue::as_bool),
Some(false)
);
assert_eq!(
effective_config.get("model"),
Some(&TomlValue::String("project-model".to_string()))
+23 -13
View File
@@ -2,6 +2,7 @@ use crate::path_utils::resolve_symlink_write_paths;
use crate::path_utils::write_atomically;
use anyhow::Context;
use codex_config::CONFIG_TOML_FILE;
use codex_config::is_structured_feature_path;
use codex_config::types::McpServerConfig;
use codex_config::types::ResumeCwdMode;
use codex_config::types::SessionPickerViewMode;
@@ -323,7 +324,7 @@ impl ConfigDocument {
Ok(self.set_skill_config(SkillConfigSelector::Name(name.clone()), *enabled))
}
ConfigEdit::SetPath { segments, value } => {
if is_multi_agent_v2_feature_path(segments) && value.as_bool().is_some() {
if is_structured_feature_path(segments) && value.as_bool().is_some() {
let mut existing = Some(self.doc.as_item());
for segment in segments {
existing = existing.and_then(|item| item.as_table_like()?.get(segment));
@@ -336,7 +337,26 @@ impl ConfigDocument {
}
Ok(self.insert(segments, value.clone()))
}
ConfigEdit::ClearPath { segments } => Ok(self.clear_owned(segments)),
ConfigEdit::ClearPath { segments } => {
let preserves_broker_settings = is_structured_feature_path(segments)
&& segments
.last()
.is_some_and(|feature| feature == "network_proxy")
&& segments
.iter()
.try_fold(self.doc.as_item(), |item, segment| {
item.as_table_like()?.get(segment)
})
.and_then(TomlItem::as_table_like)
.is_some_and(|feature| feature.contains_key("credential_broker"));
if preserves_broker_settings {
let mut enabled_segments = segments.clone();
enabled_segments.push("enabled".to_string());
Ok(self.insert(&enabled_segments, value(false)))
} else {
Ok(self.clear_owned(segments))
}
}
ConfigEdit::SetProjectTrustLevel { path, level } => {
// Delegate to the existing, tested logic in config.rs to
// ensure tables are explicit and migration is preserved.
@@ -618,7 +638,7 @@ impl ConfigDocument {
}
let item = current.get_mut(segment.as_str())?;
if is_multi_agent_v2_feature_path(&segments[..=index])
if is_structured_feature_path(&segments[..=index])
&& let Some(enabled) = item.as_bool()
{
let mut feature = document_helpers::new_implicit_table();
@@ -669,16 +689,6 @@ impl ConfigDocument {
}
}
fn is_multi_agent_v2_feature_path(segments: &[String]) -> bool {
match segments {
[features, feature] => features == "features" && feature == "multi_agent_v2",
[profiles, _, features, feature] => {
profiles == "profiles" && features == "features" && feature == "multi_agent_v2"
}
_ => false,
}
}
fn normalize_skill_config_path(path: &Path) -> String {
dunce::canonicalize(path)
.unwrap_or_else(|_| path.to_path_buf())
+31
View File
@@ -136,6 +136,37 @@ fn multi_agent_v2_feature_toggle_preserves_nested_configuration() {
);
}
#[test]
fn network_proxy_feature_toggle_preserves_credential_broker_configuration() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
let config_path = codex_home.join(CONFIG_TOML_FILE);
for (initial, disabled, enabled_again) in [
(
"[features]\nnetwork_proxy = true\n",
"[features]\n",
"[features]\nnetwork_proxy = true\n",
),
(
"[features.network_proxy]\nenabled = true\ncredential_broker = true\ndomains = { \"github.com\" = \"allow\" }\n",
"[features.network_proxy]\nenabled = false\ncredential_broker = true\ndomains = { \"github.com\" = \"allow\" }\n",
"[features.network_proxy]\nenabled = true\ncredential_broker = true\ndomains = { \"github.com\" = \"allow\" }\n",
),
] {
std::fs::write(&config_path, initial).expect("write config");
for (enabled, expected) in [(false, disabled), (true, enabled_again)] {
ConfigEditsBuilder::new(codex_home)
.set_feature_enabled("network_proxy", enabled)
.apply_blocking()
.expect("toggle feature");
let updated: TomlValue =
toml::from_str(&std::fs::read_to_string(&config_path).expect("read config"))
.expect("parse config");
assert_eq!(updated, toml::from_str::<TomlValue>(expected).unwrap());
}
}
}
/// Adding nested multi-agent settings must retain an existing legacy boolean toggle.
#[test]
fn multi_agent_v2_nested_edit_preserves_legacy_boolean_toggle() {
+4
View File
@@ -3602,6 +3602,8 @@ impl Config {
network_proxy,
);
}
configured_network_proxy_config
.set_credential_broker_openai_base_url(cfg.openai_base_url.as_deref());
configured_network_proxy_config.enabled = true;
}
if cfg.approval_policy == Some(AskForApproval::UnlessTrusted) {
@@ -4475,6 +4477,8 @@ impl Config {
network_proxy,
);
}
configured_network_proxy_config
.set_credential_broker_openai_base_url(cfg.openai_base_url.as_deref());
configured_network_proxy_config.enabled = true;
}
configured_network_proxy_config
+3
View File
@@ -189,6 +189,9 @@ pub(crate) fn apply_network_proxy_feature_config(
mitm: None,
}
.apply_to_network_proxy_config(config);
if let Some(credential_broker) = feature_config.credential_broker {
config.set_credential_broker_enabled(credential_broker);
}
}
pub(crate) fn resolve_permission_profile(
+5 -2
View File
@@ -39,6 +39,8 @@ pub(crate) fn exec_env_for_sandbox_permissions(
sandbox_permissions: SandboxPermissions,
) -> HashMap<String, String> {
let mut env = env.clone();
// Escalated commands intentionally use the original, unbrokered environment. This branch is
// defensive cleanup for a caller that passes an environment already prepared by Codex.
if sandbox_permissions.requires_escalated_permissions()
&& env.contains_key(PROXY_ACTIVE_ENV_KEY)
{
@@ -253,7 +255,7 @@ pub(crate) fn maybe_wrap_shell_lc_with_snapshot(
PLUGIN_METRICS_OUTPUT_ENV_VAR,
],
);
let (proxy_captures, proxy_exports) = build_proxy_env_exports();
let (proxy_captures, proxy_exports) = build_proxy_env_exports(env);
let runtime_path_prepend_exports =
runtime_path_prepends.shell_exports_after_snapshot(explicit_env_overrides);
let override_captures = join_shell_blocks([override_captures, proxy_captures]);
@@ -292,10 +294,11 @@ fn build_override_exports(
build_override_exports_for_keys("__CODEX_SNAPSHOT_OVERRIDE", &keys)
}
fn build_proxy_env_exports() -> (String, String) {
fn build_proxy_env_exports(env: &HashMap<String, String>) -> (String, String) {
let mut keys = PROXY_ENV_KEYS
.iter()
.copied()
.chain(codex_network_proxy::brokered_credential_env_keys(env))
.chain(CUSTOM_CA_ENV_KEYS)
.filter(|key| is_valid_shell_variable_name(key))
.collect::<Vec<_>>();
+62 -4
View File
@@ -63,10 +63,20 @@ fn shell_with_snapshot(
}
async fn test_network_proxy() -> anyhow::Result<NetworkProxy> {
let state = codex_network_proxy::build_config_state(
NetworkProxyConfig::default(),
NetworkProxyConstraints::default(),
)?;
test_network_proxy_with_config(NetworkProxyConfig::default()).await
}
pub(super) async fn test_credential_broker_network_proxy() -> anyhow::Result<NetworkProxy> {
let mut config = NetworkProxyConfig::default();
config.set_credential_broker_enabled(/*enabled*/ true);
test_network_proxy_with_config(config).await
}
async fn test_network_proxy_with_config(
config: NetworkProxyConfig,
) -> anyhow::Result<NetworkProxy> {
let state =
codex_network_proxy::build_config_state(config, NetworkProxyConstraints::default())?;
NetworkProxy::builder()
.state(Arc::new(NetworkProxyState::with_reloader(
state,
@@ -737,6 +747,54 @@ fn maybe_wrap_shell_lc_with_snapshot_restores_proxy_env_from_process_env() {
);
}
#[tokio::test]
async fn snapshot_wrapper_replays_dummy_and_preserves_unbrokered_credentials() -> anyhow::Result<()>
{
let proxy = test_credential_broker_network_proxy().await?;
let dir = tempdir()?;
let snapshot = dir.path().join("snapshot.sh");
std::fs::write(
&snapshot,
"# Snapshot file\nexport OPENAI_API_KEY='stale'\nexport GITHUB_TOKEN='ghp_snapshot_only'\nexport GH_HOST='github.example.com'\n",
)?;
let (shell, snapshot) = shell_with_snapshot(ShellType::Bash, "/bin/bash", snapshot.abs());
let mut env = HashMap::from([
(
"OPENAI_API_KEY".to_string(),
"sk-proj-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_".to_string(),
),
("GITHUB_TOKEN".to_string(), String::new()),
]);
proxy.apply_to_env(&mut env);
let dummy = env["OPENAI_API_KEY"].clone();
let command = vec![
"/bin/bash".to_string(),
"-lc".to_string(),
"printf '%s\\n%s\\n%s' \"$OPENAI_API_KEY\" \"${GITHUB_TOKEN-unset}\" \"$GH_HOST\""
.to_string(),
];
let rewritten = maybe_wrap_shell_lc_with_snapshot(
&command,
&shell,
Some(&snapshot),
&HashMap::new(),
&env,
&RuntimePathPrepends::default(),
);
let output = Command::new(&rewritten[0])
.args(&rewritten[1..])
.env_clear()
.envs(env)
.output()?;
assert!(output.status.success(), "command failed: {output:?}");
assert_eq!(
String::from_utf8_lossy(&output.stdout),
format!("{dummy}\nghp_snapshot_only\ngithub.example.com")
);
Ok(())
}
#[cfg(target_os = "macos")]
#[test]
fn maybe_wrap_shell_lc_with_snapshot_refreshes_codex_proxy_git_ssh_command() {
@@ -672,7 +672,7 @@ impl CoreShellCommandExecutor {
program: &AbsolutePathBuf,
argv: &[String],
workdir: &AbsolutePathBuf,
env: HashMap<String, String>,
mut env: HashMap<String, String>,
execution: EscalationExecution,
) -> anyhow::Result<PreparedExec> {
let command = join_program_and_argv(program, argv);
@@ -683,12 +683,20 @@ impl CoreShellCommandExecutor {
};
let prepared = match execution {
EscalationExecution::Unsandboxed => PreparedExec {
command,
cwd: workdir.to_path_buf(),
env: exec_env_for_sandbox_permissions(&env, SandboxPermissions::RequireEscalated),
arg0: Some(first_arg.clone()),
},
EscalationExecution::Unsandboxed => {
if let Some(network) = self.network.as_ref() {
network.restore_brokered_credentials(&mut env, &mut []);
}
PreparedExec {
command,
cwd: workdir.to_path_buf(),
env: exec_env_for_sandbox_permissions(
&env,
SandboxPermissions::RequireEscalated,
),
arg0: Some(first_arg.clone()),
}
}
EscalationExecution::TurnDefault => {
self.prepare_sandboxed_exec(PrepareSandboxedExecParams {
command,
@@ -10,6 +10,7 @@ use crate::config::Constrained;
use crate::guardian::GuardianReviewContext;
use crate::sandboxing::SandboxPermissions;
use crate::session::tests::make_session_and_context;
use crate::tools::runtimes::tests::test_credential_broker_network_proxy;
use anyhow::Context;
use codex_execpolicy::Decision;
use codex_execpolicy::Evaluation;
@@ -355,13 +356,14 @@ fn shell_request_escalation_execution_is_explicit() {
#[tokio::test]
async fn unsandboxed_intercepted_exec_strips_managed_network_env() -> anyhow::Result<()> {
let workdir = test_sandbox_cwd();
let network = test_credential_broker_network_proxy().await?;
let executor = CoreShellCommandExecutor {
command: Vec::new(),
cwd: workdir.clone(),
permission_profile: PermissionProfile::workspace_write(),
sandbox: SandboxType::None,
env: HashMap::new(),
network: None,
network: Some(network.clone()),
network_environment_id: None,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
arg0: None,
@@ -371,15 +373,24 @@ async fn unsandboxed_intercepted_exec_strips_managed_network_env() -> anyhow::Re
use_legacy_landlock: false,
};
let mut env = HashMap::new();
env.insert("GH_TOKEN".to_string(), "ghp-real".to_string());
env.insert(PROXY_ACTIVE_ENV_KEY.to_string(), "1".to_string());
for key in PROXY_ENV_KEYS {
env.insert((*key).to_string(), format!("proxy-{key}"));
}
network.apply_to_env(&mut env);
assert_ne!(env.get("GH_TOKEN").map(String::as_str), Some("ghp-real"));
let dummy = env.get("GH_TOKEN").expect("brokered token").clone();
let prepared = executor
.prepare_escalated_exec(
&AbsolutePathBuf::from_absolute_path("/usr/bin/curl")?,
&["curl".to_string(), "example.com".to_string()],
&[
"curl".to_string(),
"-H".to_string(),
format!("Authorization: Bearer {dummy}"),
"example.com".to_string(),
],
&workdir,
env,
EscalationExecution::Unsandboxed,
@@ -396,6 +407,19 @@ async fn unsandboxed_intercepted_exec_strips_managed_network_env() -> anyhow::Re
"unsandboxed intercepted exec should strip managed-network proxy env var {key}"
);
}
assert_eq!(
prepared.env.get("GH_TOKEN").map(String::as_str),
Some("ghp-real")
);
assert_eq!(
prepared.command,
vec![
"/usr/bin/curl".to_string(),
"-H".to_string(),
format!("Authorization: Bearer {dummy}"),
"example.com".to_string(),
]
);
Ok(())
}
@@ -1084,6 +1084,9 @@ impl UnifiedExecProcessManager {
.as_ref()
.filter(|launch| launch.policy_decision_timeout_ms.is_some())
.and_then(|_| network.and_then(NetworkProxy::remote_policy_decider));
if environment.is_remote() && network.is_some() && network_proxy_launch.is_none() {
request.exec_server_enforce_managed_network = false;
}
request.exec_server_network_proxy = network_proxy_launch;
request.exec_server_env_config = exec_server_env_config;
request.exec_server_shell_snapshot = shell_snapshot;
+83
View File
@@ -43,6 +43,7 @@ use core_test_support::responses::ev_function_call_with_namespace;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::mount_sse_once_match;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
@@ -2797,6 +2798,88 @@ async fn spawned_subagent_execpolicy_amendment_propagates_to_parent_session() ->
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[cfg(unix)]
async fn zsh_fork_receives_brokered_github_credential_and_provider_context() -> Result<()> {
skip_if_no_network!(Ok(()));
const GH_HOST: &str = "github.example.com";
const REAL_GITHUB_TOKEN: &str =
"ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let Some(runtime) = zsh_fork_runtime("zsh-fork credential broker environment test")? else {
return Ok(());
};
let home = Arc::new(TempDir::new()?);
fs::write(
home.path().join("config.toml"),
r#"default_permissions = "brokered"
features = { network_proxy = { enabled = true, credential_broker = true } }
permissions = { brokered = { extends = ":workspace", network = { enabled = true, allow_local_binding = true } } }
"#,
)?;
let mut builder = zsh_fork_test_builder(runtime, AskForApproval::Never)
.with_home(home)
.with_cloud_config_bundle(managed_network_requirements_loader())
.with_config(|config| {
config.permissions.shell_environment_policy.r#set.extend([
("GH_HOST".to_string(), GH_HOST.to_string()),
(
"GH_ENTERPRISE_TOKEN".to_string(),
REAL_GITHUB_TOKEN.to_string(),
),
]);
});
let server = start_mock_server().await;
let test = builder.build(&server).await?;
let call_id = "zsh-fork-brokered-github-credential";
let command = r#"printf '%s\n%s\n%s\n' "$GH_HOST" "$GH_ENTERPRISE_TOKEN" "$CODEX_NETWORK_PROXY_CREDENTIAL_BROKER_ACTIVE""#;
let event = shell_event(
call_id,
command,
/*timeout_ms*/ 10_000,
SandboxPermissions::UseDefault,
)?;
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-zsh-fork-broker-1"),
event,
ev_completed("resp-zsh-fork-broker-1"),
]),
sse(vec![
ev_assistant_message("msg-zsh-fork-broker", "done"),
ev_completed("resp-zsh-fork-broker-2"),
]),
],
)
.await;
submit_turn_preserving_active_permission_profile(
&test,
"show the brokered GitHub environment",
AskForApproval::Never,
)
.await?;
wait_for_completion_without_approval(&test).await;
let result = parse_result(&responses.requests()[1].function_call_output(call_id));
assert_eq!(result.exit_code, Some(0), "command failed: {result:?}");
assert!(!result.stdout.contains(REAL_GITHUB_TOKEN));
let values = result.stdout.lines().collect::<Vec<_>>();
assert_eq!(values.len(), 3, "unexpected command output: {result:?}");
assert_eq!(values[0], GH_HOST);
assert_ne!(values[1], REAL_GITHUB_TOKEN);
assert!(values[1].starts_with("ghp_"));
assert_eq!(values[1].len(), REAL_GITHUB_TOKEN.len());
assert_eq!(values[2], "1");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[cfg(unix)]
async fn env_zsh_script_spawned_by_python_can_request_escalation_under_zsh_fork() -> Result<()> {