[codex] harden workload identity bootstrap and isolation [ci changed_files]

This commit is contained in:
Cooper Gamble
2026-06-17 17:00:15 +00:00
parent 46017a628f
commit ed2ea13f4b
17 changed files with 374 additions and 54 deletions
@@ -234,6 +234,7 @@ async fn plugin_list_local_succeeds_when_workload_identity_becomes_unavailable()
let response: PluginListResponse = to_response(response)?;
assert!(response.marketplace_load_errors.is_empty());
server.verify().await;
Ok(())
}
+48 -6
View File
@@ -7,6 +7,8 @@ use codex_config::CloudConfigBundleLoader;
use codex_config::types::AuthCredentialsStoreMode;
use codex_login::AuthKeyringBackendKind;
use codex_login::AuthManager;
use codex_login::AuthManagerConfig;
use codex_login::WorkloadIdentityConfig;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;
@@ -18,6 +20,40 @@ fn refresher_task_slot() -> &'static Mutex<Option<JoinHandle<()>>> {
REFRESHER_TASK.get_or_init(|| Mutex::new(None))
}
struct StorageAuthManagerConfig {
codex_home: PathBuf,
credentials_store_mode: AuthCredentialsStoreMode,
keyring_backend_kind: AuthKeyringBackendKind,
chatgpt_base_url: String,
workload_identity: Option<WorkloadIdentityConfig>,
}
impl AuthManagerConfig for StorageAuthManagerConfig {
fn codex_home(&self) -> PathBuf {
self.codex_home.clone()
}
fn cli_auth_credentials_store_mode(&self) -> AuthCredentialsStoreMode {
self.credentials_store_mode
}
fn auth_keyring_backend_kind(&self) -> AuthKeyringBackendKind {
self.keyring_backend_kind
}
fn forced_chatgpt_workspace_id(&self) -> Option<Vec<String>> {
None
}
fn chatgpt_base_url(&self) -> String {
self.chatgpt_base_url.clone()
}
fn workload_identity(&self) -> Option<WorkloadIdentityConfig> {
self.workload_identity.clone()
}
}
pub fn cloud_config_bundle_loader(
auth_manager: Arc<AuthManager>,
chatgpt_base_url: String,
@@ -58,14 +94,20 @@ pub async fn cloud_config_bundle_loader_for_storage(
credentials_store_mode: AuthCredentialsStoreMode,
keyring_backend_kind: AuthKeyringBackendKind,
chatgpt_base_url: String,
workload_identity: Option<WorkloadIdentityConfig>,
) -> CloudConfigBundleLoader {
let auth_manager = AuthManager::shared(
codex_home.clone(),
enable_codex_api_key_env,
let auth_config = StorageAuthManagerConfig {
codex_home: codex_home.clone(),
credentials_store_mode,
Some(chatgpt_base_url.clone()),
keyring_backend_kind,
)
.await;
chatgpt_base_url: chatgpt_base_url.clone(),
workload_identity,
};
let auth_manager =
AuthManager::shared_from_config(&auth_config, enable_codex_api_key_env).await;
cloud_config_bundle_loader(auth_manager, chatgpt_base_url, codex_home)
}
#[cfg(test)]
#[path = "bundle_loader_tests.rs"]
mod tests;
@@ -0,0 +1,37 @@
use super::*;
use codex_config::CloudConfigBundleLoadErrorCode;
use serde_json::json;
use tempfile::tempdir;
#[tokio::test]
async fn storage_loader_consults_workload_identity_without_stored_auth() {
let codex_home = tempdir().expect("tempdir");
let missing_token_path = codex_home.path().join("missing-subject-token");
let workload_identity = serde_json::from_value(json!({
"identity_provider_id": "idp_test",
"identity_provider_mapping_id": "idpm_test",
"audience": "api://codex-test",
"token_url": "http://127.0.0.1:1/oauth/token",
"credential_source": {
"type": "file",
"path": missing_token_path,
},
}))
.expect("valid workload identity config");
let loader = cloud_config_bundle_loader_for_storage(
codex_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
AuthKeyringBackendKind::default(),
"http://127.0.0.1:1/backend-api".to_string(),
Some(workload_identity),
)
.await;
let error = loader
.get()
.await
.expect_err("missing WIF source should fail managed config auth");
assert_eq!(error.code(), CloudConfigBundleLoadErrorCode::Auth);
}
+2 -1
View File
@@ -244,7 +244,8 @@ pub(crate) async fn build_guardian_prompt_items_with_parent_turn(
fn parent_turn_denied_reads_context(turn: &TurnContext) -> Option<String> {
#[allow(deprecated)]
let cwd = &turn.cwd;
let file_system_policy = turn.permission_profile.file_system_sandbox_policy();
let permission_profile = turn.model_visible_permission_profile();
let file_system_policy = permission_profile.file_system_sandbox_policy();
let mut entries = file_system_policy
.get_unreadable_roots_with_cwd(cwd)
.into_iter()
+10
View File
@@ -463,6 +463,7 @@ async fn build_guardian_prompt_includes_parent_turn_denied_reads() -> anyhow::Re
session.thread_id = fixed_guardian_parent_session_id();
let denied_root = test_path_buf("/repo/private").abs();
let denied_glob = test_path_buf("/repo/private/**").display().to_string();
let credential_path = test_path_buf("/run/secrets/codex-wif/token").abs();
turn.permission_profile = PermissionProfile::from_runtime_permissions(
&FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
@@ -483,9 +484,17 @@ async fn build_guardian_prompt_includes_parent_turn_denied_reads() -> anyhow::Re
},
access: FileSystemAccessMode::Deny,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: credential_path.clone(),
},
access: FileSystemAccessMode::Deny,
},
]),
NetworkSandboxPolicy::Restricted,
);
Arc::make_mut(&mut turn.config).workload_identity_credential_deny_paths =
vec![credential_path.clone()];
let session = Arc::new(session);
let turn = Arc::new(turn);
seed_guardian_parent_history(&session, &turn).await;
@@ -511,6 +520,7 @@ async fn build_guardian_prompt_includes_parent_turn_denied_reads() -> anyhow::Re
assert!(text.contains("do not approve escalation whose purpose is to read them"));
assert!(text.contains(denied_root.to_string_lossy().as_ref()));
assert!(text.contains(&format!("glob `{denied_glob}`")));
assert!(!text.contains(credential_path.to_string_lossy().as_ref()));
Ok(())
}
+23 -1
View File
@@ -19,6 +19,17 @@ use codex_login::CodexAuth;
use codex_utils_path_uri::PathUri;
use serde_json::Value as JsonValue;
const OPENAI_FILE_AUTH_RESOLUTION_FAILED_MESSAGE: &str =
"failed to resolve ChatGPT auth for OpenAI file upload";
fn model_visible_auth_resolution_error(error: std::io::Error) -> String {
tracing::warn!(
error = %error,
"failed to resolve auth for OpenAI file upload"
);
OPENAI_FILE_AUTH_RESOLUTION_FAILED_MESSAGE.to_string()
}
pub(crate) async fn rewrite_mcp_tool_arguments_for_openai_files(
sess: &Session,
turn_context: &TurnContext,
@@ -40,7 +51,7 @@ pub(crate) async fn rewrite_mcp_tool_arguments_for_openai_files(
.auth_manager
.auth()
.await
.map_err(|err| format!("failed to resolve auth for OpenAI file upload: {err}"))?;
.map_err(model_visible_auth_resolution_error)?;
let mut rewritten_arguments = arguments.clone();
for field_name in openai_file_input_params {
@@ -195,6 +206,17 @@ mod tests {
use std::sync::Arc;
use tempfile::tempdir;
#[test]
fn auth_resolution_errors_hide_credential_source_details() {
let credential_path = "/run/secrets/codex-wif/subject-token";
let message = model_visible_auth_resolution_error(std::io::Error::other(format!(
"file credential source could not read {credential_path}"
)));
assert_eq!(message, OPENAI_FILE_AUTH_RESOLUTION_FAILED_MESSAGE);
assert!(!message.contains(credential_path));
}
fn set_primary_environment_cwd(turn_context: &mut TurnContext, cwd: &Path) {
let cwd = AbsolutePathBuf::try_from(cwd).expect("absolute path");
turn_context.permission_profile = codex_protocol::models::PermissionProfile::Disabled;
+100 -1
View File
@@ -1,8 +1,10 @@
use std::collections::HashMap;
use std::path::PathBuf;
use anyhow::Result;
use anyhow::bail;
use base64::Engine;
use codex_config::types::ShellEnvironmentPolicyToml;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_workload_identity::CredentialSourceConfig;
@@ -26,17 +28,32 @@ const ACCESS_TOKEN_TYPE: &str = "urn:ietf:params:oauth:token-type:access_token";
#[derive(Serialize)]
struct TestConfigToml {
workload_identity: WorkloadIdentityConfig,
#[serde(skip_serializing_if = "Option::is_none")]
shell_environment_policy: Option<ShellEnvironmentPolicyToml>,
}
fn workload_identity_config_toml(server: &MockServer, token_path: PathBuf) -> Result<String> {
workload_identity_config_toml_with_source(
server,
CredentialSourceConfig::File { path: token_path },
/*shell_environment_policy*/ None,
)
}
fn workload_identity_config_toml_with_source(
server: &MockServer,
credential_source: CredentialSourceConfig,
shell_environment_policy: Option<ShellEnvironmentPolicyToml>,
) -> Result<String> {
Ok(toml::to_string(&TestConfigToml {
workload_identity: WorkloadIdentityConfig {
identity_provider_id: "idp_test".to_string(),
identity_provider_mapping_id: "idpm_test".to_string(),
audience: "api://codex-test".to_string(),
token_url: format!("{}/oauth/token", server.uri()),
credential_source: CredentialSourceConfig::File { path: token_path },
credential_source,
},
shell_environment_policy,
})?)
}
@@ -137,6 +154,88 @@ async fn configured_workload_identity_authenticates_responses_turn() -> Result<(
Ok(())
}
#[cfg(not(target_os = "windows"))]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn workload_identity_credentials_are_unavailable_to_shell_tools() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = MockServer::start().await;
let credential_dir = TempDir::new()?;
let token_path = credential_dir.path().join("azure-subject-token");
std::fs::write(&token_path, "external-subject-token\n")?;
let access_token = test_access_token();
Mock::given(method("POST"))
.and(path("/oauth/token"))
.respond_with(exchange_response(&access_token))
.expect(1)
.mount(&server)
.await;
let shell_call_id = "wif-isolation-shell";
let token_path_string = token_path.to_string_lossy();
assert!(!token_path_string.contains('\''));
let command = format!(
"if [ -z \"${{AZURE_FEDERATED_TOKEN_FILE+x}}\" ]; then printf 'env-hidden\\n'; else printf 'env-visible:%s\\n' \"$AZURE_FEDERATED_TOKEN_FILE\"; fi; if cat '{token_path_string}' >/dev/null 2>&1; then printf 'file-readable\\n'; else printf 'file-denied\\n'; fi"
);
let shell_arguments = serde_json::to_string(&json!({
"command": command,
"login": false,
"timeout_ms": 5_000,
}))?;
let response_mock = responses::mount_sse_sequence(
&server,
vec![
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call(shell_call_id, "shell_command", &shell_arguments),
responses::ev_completed("resp-1"),
]),
responses::sse(vec![
responses::ev_assistant_message("msg-1", "isolated"),
responses::ev_completed("resp-2"),
]),
],
)
.await;
let config_toml = workload_identity_config_toml_with_source(
&server,
CredentialSourceConfig::Azure {
token_file: Some(token_path.clone()),
},
Some(ShellEnvironmentPolicyToml {
r#set: Some(HashMap::from([(
"AZURE_FEDERATED_TOKEN_FILE".to_string(),
"credential-env-must-not-leak".to_string(),
)])),
..Default::default()
}),
)?;
let mut builder = test_codex_with_workload_identity(config_toml);
let test = builder.build(&server).await?;
test.submit_turn_with_permission_profile(
"verify workload credentials are isolated",
test.config.permissions.permission_profile().clone(),
)
.await?;
let output = response_mock
.function_call_output_text(shell_call_id)
.expect("shell output present");
assert!(
output.contains("env-hidden"),
"unexpected shell output: {output}"
);
assert!(
output.contains("file-denied"),
"unexpected shell output: {output}"
);
assert!(!output.contains("credential-env-must-not-leak"));
assert!(!output.contains("external-subject-token"));
server.verify().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn required_workload_identity_failure_blocks_session_startup() -> Result<()> {
skip_if_no_network!(Ok(()));
+1
View File
@@ -357,6 +357,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
.unwrap_or_default(),
resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)?,
chatgpt_base_url,
bootstrap_config_toml.workload_identity.clone(),
)
.await;
let run_cli_overrides = cli_kv_overrides.clone();
+2 -39
View File
@@ -138,42 +138,5 @@ fn default_shell_command() -> Command {
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::ffi::OsStr;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookSource;
use codex_utils_absolute_path::AbsolutePathBuf;
use super::*;
#[test]
fn excluded_environment_variable_overrides_handler_env() {
let variable = "CODEX_WIF_HOOK_ASSERTION";
let shell = CommandShell {
program: String::new(),
args: Vec::new(),
excluded_environment_variables: vec![variable.to_string()],
};
let handler = ConfiguredHandler {
event_name: HookEventName::PreToolUse,
matcher: None,
command: "true".to_string(),
timeout_sec: 1,
status_message: None,
source_path: AbsolutePathBuf::current_dir().expect("current dir"),
source: HookSource::Unknown,
display_order: 0,
env: HashMap::from([(variable.to_string(), "secret.assertion".to_string())]),
};
let command = build_command(&shell, &handler);
assert!(
command
.as_std()
.get_envs()
.any(|(name, value)| { name == OsStr::new(variable) && value.is_none() })
);
}
}
#[path = "command_runner_tests.rs"]
mod tests;
@@ -0,0 +1,37 @@
use std::collections::HashMap;
use std::ffi::OsStr;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookSource;
use codex_utils_absolute_path::AbsolutePathBuf;
use super::*;
#[test]
fn excluded_environment_variable_overrides_handler_env() {
let variable = "CODEX_WIF_HOOK_ASSERTION";
let shell = CommandShell {
program: String::new(),
args: Vec::new(),
excluded_environment_variables: vec![variable.to_string()],
};
let handler = ConfiguredHandler {
event_name: HookEventName::PreToolUse,
matcher: None,
command: "true".to_string(),
timeout_sec: 1,
status_message: None,
source_path: AbsolutePathBuf::current_dir().expect("current dir"),
source: HookSource::Unknown,
display_order: 0,
env: HashMap::from([(variable.to_string(), "secret.assertion".to_string())]),
};
let command = build_command(&shell, &handler);
assert!(
command
.as_std()
.get_envs()
.any(|(name, value)| { name == OsStr::new(variable) && value.is_none() })
);
}
+3 -4
View File
@@ -29,7 +29,7 @@ use super::access_token::CodexAccessToken;
use super::access_token::classify_codex_access_token;
use super::external_bearer::BearerTokenRefresher;
use super::revoke::revoke_auth_tokens;
use super::workload_identity::WorkloadIdentityExternalAuth;
use super::workload_identity::shared_workload_identity_external_auth;
pub use crate::auth::agent_identity::AgentIdentityAuth;
pub use crate::auth::bedrock_api_key::BedrockApiKeyAuth;
pub use crate::auth::personal_access_token::PersonalAccessTokenAuth;
@@ -51,7 +51,6 @@ use codex_protocol::auth::PlanType as InternalPlanType;
use codex_protocol::auth::RefreshTokenFailedError;
use codex_protocol::auth::RefreshTokenFailedReason;
use codex_workload_identity_providers::WorkloadIdentityConfig;
use codex_workload_identity_providers::build_client as build_workload_identity_client;
use serde_json::Value;
use thiserror::Error;
@@ -1961,12 +1960,12 @@ impl AuthManager {
.await,
);
if let Some(workload_identity) = config.workload_identity() {
let client = build_workload_identity_client(
let external_auth = shared_workload_identity_external_auth(
workload_identity,
oauth_client_id(),
build_reqwest_client(),
);
auth_manager.set_external_auth(Arc::new(WorkloadIdentityExternalAuth::new(client)));
auth_manager.set_external_auth(external_auth);
}
auth_manager
}
+56 -2
View File
@@ -1,5 +1,12 @@
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::OnceLock;
use std::sync::Weak;
use codex_app_server_protocol::AuthMode;
use codex_workload_identity_providers::ConfiguredWorkloadIdentityClient;
use codex_workload_identity_providers::WorkloadIdentityConfig;
use codex_workload_identity_providers::build_client as build_workload_identity_client;
use super::ExternalAuth;
use super::ExternalAuthFuture;
@@ -11,6 +18,45 @@ pub(super) struct WorkloadIdentityExternalAuth {
process_isolation_error: Option<String>,
}
struct SharedWorkloadIdentityExternalAuth {
config: WorkloadIdentityConfig,
client_id: String,
auth: Weak<WorkloadIdentityExternalAuth>,
}
fn shared_workload_identity_auths() -> &'static Mutex<Vec<SharedWorkloadIdentityExternalAuth>> {
static SHARED_AUTHS: OnceLock<Mutex<Vec<SharedWorkloadIdentityExternalAuth>>> = OnceLock::new();
SHARED_AUTHS.get_or_init(|| Mutex::new(Vec::new()))
}
pub(super) fn shared_workload_identity_external_auth(
config: WorkloadIdentityConfig,
client_id: String,
http: reqwest::Client,
) -> Arc<dyn ExternalAuth> {
let mut shared_auths = shared_workload_identity_auths()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
shared_auths.retain(|entry| entry.auth.strong_count() > 0);
if let Some(auth) = shared_auths.iter().find_map(|entry| {
(entry.config == config && entry.client_id == client_id)
.then(|| entry.auth.upgrade())
.flatten()
}) {
return auth;
}
let auth = Arc::new(WorkloadIdentityExternalAuth::new(
build_workload_identity_client(config.clone(), client_id.clone(), http),
));
shared_auths.push(SharedWorkloadIdentityExternalAuth {
config,
client_id,
auth: Arc::downgrade(&auth),
});
auth
}
impl WorkloadIdentityExternalAuth {
pub(super) fn new(client: ConfiguredWorkloadIdentityClient) -> Self {
#[cfg(target_os = "windows")]
@@ -56,13 +102,21 @@ impl ExternalAuth for WorkloadIdentityExternalAuth {
}
fn resolve(&self) -> ExternalAuthFuture<'_, Option<ExternalAuthTokens>> {
Box::pin(async move { self.tokens(false).await.map(Some) })
Box::pin(async move {
self.tokens(/*force_refresh*/ false).await.map(Some)
})
}
fn refresh(
&self,
_context: ExternalAuthRefreshContext,
) -> ExternalAuthFuture<'_, ExternalAuthTokens> {
Box::pin(async move { self.tokens(true).await })
Box::pin(async move {
self.tokens(/*force_refresh*/ true).await
})
}
}
#[cfg(test)]
#[path = "workload_identity_tests.rs"]
mod tests;
@@ -0,0 +1,49 @@
use super::*;
use serde_json::json;
fn test_config(mapping_id: &str) -> WorkloadIdentityConfig {
serde_json::from_value(json!({
"identity_provider_id": "idp_test",
"identity_provider_mapping_id": mapping_id,
"audience": "api://codex-test",
"token_url": "http://127.0.0.1:1/oauth/token",
"credential_source": {
"type": "file",
"path": "/run/secrets/codex-wif/subject-token",
},
}))
.expect("valid workload identity config")
}
#[test]
fn identical_config_reuses_process_scoped_external_auth() {
let config = test_config("idpm_shared");
let first = shared_workload_identity_external_auth(
config.clone(),
"client-test".to_string(),
reqwest::Client::new(),
);
let second = shared_workload_identity_external_auth(
config,
"client-test".to_string(),
reqwest::Client::new(),
);
assert!(Arc::ptr_eq(&first, &second));
}
#[test]
fn different_config_uses_distinct_external_auth() {
let first = shared_workload_identity_external_auth(
test_config("idpm_first"),
"client-test".to_string(),
reqwest::Client::new(),
);
let second = shared_workload_identity_external_auth(
test_config("idpm_second"),
"client-test".to_string(),
reqwest::Client::new(),
);
assert!(!Arc::ptr_eq(&first, &second));
}
+1
View File
@@ -52,4 +52,5 @@ pub use auth::read_openai_api_key_from_env;
pub use auth::save_auth;
pub use auth_env_telemetry::AuthEnvTelemetry;
pub use auth_env_telemetry::collect_auth_env_telemetry;
pub use codex_workload_identity_providers::WorkloadIdentityConfig;
pub use token_data::TokenData;
+2
View File
@@ -964,6 +964,7 @@ pub async fn run_main(
.unwrap_or_default(),
resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)?,
chatgpt_base_url,
bootstrap_config_toml.workload_identity.clone(),
)
.await;
@@ -1428,6 +1429,7 @@ async fn run_ratatui_app(
initial_config.cli_auth_credentials_store_mode,
initial_config.auth_keyring_backend_kind(),
initial_config.chatgpt_base_url.clone(),
initial_config.workload_identity.clone(),
)
.await;
}
+1
View File
@@ -1040,6 +1040,7 @@ mod tests {
AuthCredentialsStoreMode::File,
AuthKeyringBackendKind::default(),
"https://chatgpt.com/backend-api/".to_string(),
/*workload_identity*/ None,
)
.await,
feedback: codex_feedback::CodexFeedback::new(),
@@ -335,6 +335,7 @@ async fn start_app_server_for_archive_command(
config_toml.cli_auth_credentials_store.unwrap_or_default(),
resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)?,
chatgpt_base_url,
config_toml.workload_identity.clone(),
)
.await;