From bb2b8ba0ae12987131026397beec8cd8b93d0812 Mon Sep 17 00:00:00 2001 From: Cooper Gamble Date: Wed, 17 Jun 2026 04:53:02 +0000 Subject: [PATCH] [codex] close cross-platform workload identity gaps [ci changed_files] --- codex-rs/core/src/config/config_tests.rs | 70 ++++++++++++++++++++ codex-rs/core/src/config/mod.rs | 53 +++++++++++---- codex-rs/login/src/auth/workload_identity.rs | 5 ++ 3 files changed, 116 insertions(+), 12 deletions(-) diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 0a21b333c6..bf41dd667e 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -409,6 +409,76 @@ async fn rotating_workload_credential_denies_its_secret_directory() -> anyhow::R Ok(()) } +#[cfg(unix)] +#[tokio::test] +async fn workload_credential_symlink_denies_external_target() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let secret_dir = codex_home.path().join("projected-secret"); + let external_dir = codex_home.path().join("external-secret"); + std::fs::create_dir_all(&secret_dir)?; + std::fs::create_dir_all(&external_dir)?; + let target = external_dir.join("token"); + std::fs::write(&target, "subject-token")?; + let token_file = secret_dir.join("token"); + std::os::unix::fs::symlink(&target, &token_file)?; + + let cfg = ConfigToml { + workload_identity: Some(WorkloadIdentityConfig { + identity_provider_id: "idp_example".to_string(), + identity_provider_mapping_id: "idpm_example".to_string(), + audience: "openai-audience".to_string(), + token_url: "https://auth.openai.com/oauth/token".to_string(), + credential_source: CredentialSourceConfig::File { + path: token_file.clone(), + }, + }), + ..Default::default() + }; + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await?; + + let file_system_policy = config.permissions.file_system_sandbox_policy(); + let matcher = ReadDenyMatcher::new(&file_system_policy, config.cwd.as_path()) + .expect("workload credential should install a deny-read matcher"); + assert!(matcher.is_read_denied(&token_file)); + assert!(matcher.is_read_denied(&target)); + Ok(()) +} + +#[cfg(target_os = "windows")] +#[tokio::test] +async fn workload_identity_enables_windows_restricted_token_sandbox() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let cfg = ConfigToml { + workload_identity: Some(WorkloadIdentityConfig { + identity_provider_id: "idp_example".to_string(), + identity_provider_mapping_id: "idpm_example".to_string(), + audience: "openai-audience".to_string(), + token_url: "https://auth.openai.com/oauth/token".to_string(), + credential_source: CredentialSourceConfig::Environment { + variable: "WORKLOAD_IDENTITY_TOKEN".to_string(), + }, + }), + ..Default::default() + }; + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await?; + + assert_eq!( + config.permissions.windows_sandbox_mode, + Some(WindowsSandboxModeToml::Unelevated) + ); + Ok(()) +} + async fn derive_legacy_sandbox_policy_for_test( cfg: &ConfigToml, sandbox_mode_override: Option, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index bf175f63c6..798960d9e4 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -2353,7 +2353,7 @@ fn apply_workload_identity_filesystem_constraints( .credential_source .credential_file_paths() .into_iter() - .filter_map(workload_identity_credential_deny_path) + .flat_map(workload_identity_credential_deny_paths) .map(|path| codex_protocol::permissions::FileSystemSandboxEntry { path: codex_protocol::permissions::FileSystemPath::Path { path }, access: codex_protocol::permissions::FileSystemAccessMode::Deny, @@ -2367,23 +2367,38 @@ fn apply_workload_identity_filesystem_constraints( .preserve_deny_read_restrictions_from(&FileSystemSandboxPolicy::restricted(entries)); } -fn workload_identity_credential_deny_path(path: PathBuf) -> Option { - let path = AbsolutePathBuf::relative_to_current_dir(path).ok()?; +fn workload_identity_credential_deny_paths(path: PathBuf) -> Vec { + let Ok(path) = AbsolutePathBuf::relative_to_current_dir(path) else { + return Vec::new(); + }; let Some(parent) = path.as_path().parent() else { - return Some(path); + return vec![path]; }; let parent = std::fs::canonicalize(parent).unwrap_or_else(|_| parent.to_path_buf()); // Projected service-account tokens are symlinks whose targets rotate. Mask // the containing secret directory so a rotation cannot expose a new target. - let deny_path = if std::fs::symlink_metadata(path.as_path()) - .is_ok_and(|metadata| metadata.file_type().is_symlink()) + let is_symlink = std::fs::symlink_metadata(path.as_path()) + .is_ok_and(|metadata| metadata.file_type().is_symlink()); + if !is_symlink { + return path + .as_path() + .file_name() + .and_then(|file_name| AbsolutePathBuf::from_absolute_path(parent.join(file_name)).ok()) + .into_iter() + .collect(); + } + + let mut deny_paths = AbsolutePathBuf::from_absolute_path(&parent) + .into_iter() + .collect::>(); + if let Ok(target) = std::fs::canonicalize(path.as_path()) + && !target.starts_with(&parent) + && let Ok(target) = AbsolutePathBuf::from_absolute_path(target) { - parent - } else { - parent.join(path.as_path().file_name()?) - }; - AbsolutePathBuf::from_absolute_path(deny_path).ok() + deny_paths.push(target); + } + deny_paths } /// Optional overrides for user configuration (e.g., from CLI flags). @@ -2868,7 +2883,12 @@ impl Config { )?; let respect_system_proxy = features.enabled(Feature::RespectSystemProxy); let enable_network_proxy = features.enabled(Feature::NetworkProxy); - let configured_windows_sandbox_mode = resolve_windows_sandbox_mode(&cfg); + // A restricted child token is the Windows boundary that prevents model-controlled + // processes from inspecting WIF credentials held by the parent process. + let configured_windows_sandbox_mode = resolve_windows_sandbox_mode(&cfg).or_else(|| { + (cfg!(target_os = "windows") && cfg.workload_identity.is_some()) + .then_some(WindowsSandboxModeToml::Unelevated) + }); // Keep the configured mode separate so a requirement-constrained mode // does not look like it was explicitly selected in config. let selected_windows_sandbox_mode = configured_windows_sandbox_mode.or_else(|| { @@ -2885,6 +2905,15 @@ impl Config { &mut startup_warnings, )?; let effective_windows_sandbox_mode = *constrained_windows_sandbox_mode.get(); + if cfg!(target_os = "windows") + && cfg.workload_identity.is_some() + && effective_windows_sandbox_mode.is_none() + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "`workload_identity` requires the Windows restricted-token sandbox", + )); + } let windows_sandbox_mode = if constrained_windows_sandbox_mode.source.is_some() { effective_windows_sandbox_mode } else { diff --git a/codex-rs/login/src/auth/workload_identity.rs b/codex-rs/login/src/auth/workload_identity.rs index b2540f9c11..b6ff943921 100644 --- a/codex-rs/login/src/auth/workload_identity.rs +++ b/codex-rs/login/src/auth/workload_identity.rs @@ -13,6 +13,11 @@ pub(super) struct WorkloadIdentityExternalAuth { impl WorkloadIdentityExternalAuth { pub(super) fn new(client: ConfiguredWorkloadIdentityClient) -> Self { + #[cfg(target_os = "windows")] + // Core config forces WIF sessions onto the Windows restricted-token sandbox, which keeps + // model-controlled child processes from opening the parent process. + let process_isolation_error = None; + #[cfg(not(target_os = "windows"))] let process_isolation_error = codex_process_hardening::disable_process_inspection() .err() .map(|error| format!("workload identity process isolation failed: {error}"));