From 4c688697db1f9b6a268b436cf8cc75d862067672 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Mon, 1 Jun 2026 12:41:50 -0700 Subject: [PATCH] fix(config): merge nested permissions requirements Co-authored-by: Codex noreply@openai.com --- codex-rs/config/src/config_requirements.rs | 83 +++++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 540a78a616..5630aea812 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -991,11 +991,21 @@ impl ConfigRequirementsWithSources { rules, enforce_residency, network, - permissions, guardian_policy_config, } ); + if let Some(incoming_permissions) = other.permissions.take() { + if let Some(existing_permissions) = self.permissions.as_mut() { + merge_permissions_requirements_descending( + &mut existing_permissions.value, + incoming_permissions, + ); + } else { + self.permissions = Some(Sourced::new(incoming_permissions, source.clone())); + } + } + if let Some(incoming_apps) = other.apps.take() { if let Some(existing_apps) = self.apps.as_mut() { merge_app_requirements_descending(&mut existing_apps.value, incoming_apps); @@ -1054,6 +1064,21 @@ impl ConfigRequirementsWithSources { } } +fn merge_permissions_requirements_descending( + existing: &mut PermissionsRequirementsToml, + mut incoming: PermissionsRequirementsToml, +) { + if existing.filesystem.is_none() { + existing.filesystem = incoming.filesystem.take(); + } + if existing.network.is_none() { + existing.network = incoming.network.take(); + } + for (profile_id, profile) in incoming.profiles { + existing.profiles.entry(profile_id).or_insert(profile); + } +} + fn normalize_hostname(hostname: &str) -> Option { let hostname = hostname.trim().trim_end_matches('.'); (!hostname.is_empty()).then(|| hostname.to_ascii_lowercase()) @@ -1965,6 +1990,62 @@ mod tests { Ok(()) } + #[test] + fn merge_unset_fields_merges_nested_permissions_requirements() -> Result<()> { + let deny_read = if cfg!(windows) { + r"C:\Users\alice\.ssh" + } else { + "/home/alice/.ssh" + }; + let high_precedence: ConfigRequirementsToml = from_str(&format!( + r#" + [permissions.filesystem] + deny_read = [{deny_read:?}] + "# + ))?; + let low_precedence: ConfigRequirementsToml = from_str( + r#" + [permissions.network] + enabled = true + + [permissions.managed] + sandbox_mode = "workspace-write" + "#, + )?; + + let mut target = ConfigRequirementsWithSources::default(); + target.merge_unset_fields(RequirementSource::CloudRequirements, high_precedence); + target.merge_unset_fields(RequirementSource::Unknown, low_precedence); + let merged_toml = target.clone().into_toml(); + let requirements = ConfigRequirements::try_from(target)?; + + assert!( + merged_toml + .permissions + .as_ref() + .is_some_and(|permissions| permissions.profiles.contains_key("managed")) + ); + assert_eq!( + requirements.filesystem, + Some(Sourced::new( + FilesystemConstraints { + deny_read: vec![AbsolutePathBuf::from_absolute_path(deny_read)?.into()], + }, + RequirementSource::CloudRequirements, + )) + ); + assert_eq!( + requirements + .network + .expect("network requirements should merge") + .value + .enabled, + Some(true) + ); + + Ok(()) + } + #[test] fn merge_unset_fields_ignores_blank_guardian_override() { let mut target = ConfigRequirementsWithSources::default();