mirror of
https://github.com/openai/codex.git
synced 2026-10-29 19:08:43 +00:00
[codex] close cross-platform workload identity gaps [ci changed_files]
This commit is contained in:
@@ -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<SandboxMode>,
|
||||
|
||||
@@ -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<AbsolutePathBuf> {
|
||||
let path = AbsolutePathBuf::relative_to_current_dir(path).ok()?;
|
||||
fn workload_identity_credential_deny_paths(path: PathBuf) -> Vec<AbsolutePathBuf> {
|
||||
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::<Vec<_>>();
|
||||
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 {
|
||||
|
||||
@@ -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}"));
|
||||
|
||||
Reference in New Issue
Block a user