diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 93d16d3ccf..b28b0470e3 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -5,12 +5,14 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; +use serde::Deserializer; use serde::Serialize; use serde::de::Error as _; use serde::de::value::Error as ValueDeserializerError; use serde::de::value::StrDeserializer; use std::collections::BTreeMap; use std::fmt; +use toml::Value as TomlValue; use wildmatch::WildMatchPattern; use super::requirements_exec_policy::RequirementsExecPolicy; @@ -749,7 +751,7 @@ pub struct ConfigRequirementsToml { pub allowed_approvals_reviewers: Option>, pub allowed_sandbox_modes: Option>, pub allowed_permissions: Option>, - pub remote_sandbox_config: Option>, + pub remote_sandbox_config: Option, pub allowed_web_search_modes: Option>, pub allow_managed_hooks_only: Option, pub allow_appshots: Option, @@ -768,6 +770,63 @@ pub struct ConfigRequirementsToml { pub guardian_policy_config: Option, } +#[derive(Debug, Clone, Default, PartialEq)] +pub struct RemoteSandboxConfigsToml { + pub entries: Vec, +} + +impl RemoteSandboxConfigsToml { + fn matching_allowed_sandbox_modes( + &self, + hostname: &str, + ) -> Option> { + let mut matching_configs = self + .entries + .iter() + .filter(|config| hostname_matches_any_pattern(hostname, &config.hostname_patterns)); + let first_config = matching_configs.next()?; + let mut allowed_sandbox_modes = first_config.allowed_sandbox_modes.clone(); + for config in matching_configs { + allowed_sandbox_modes.retain(|mode| config.allowed_sandbox_modes.contains(mode)); + } + Some(allowed_sandbox_modes) + } +} + +impl From> for RemoteSandboxConfigsToml { + fn from(entries: Vec) -> Self { + Self { entries } + } +} + +impl<'de> Deserialize<'de> for RemoteSandboxConfigsToml { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + match TomlValue::deserialize(deserializer)? { + TomlValue::Array(entries) => Ok(Self { + entries: entries + .into_iter() + .map(RemoteSandboxConfigToml::deserialize) + .collect::, _>>() + .map_err(D::Error::custom)?, + }), + TomlValue::Table(entries) => { + let entries = entries + .into_iter() + .map(|(_, entry)| RemoteSandboxConfigToml::deserialize(entry)) + .collect::, _>>() + .map_err(D::Error::custom)?; + Ok(Self { entries }) + } + value => Err(D::Error::custom(format!( + "expected array or table for remote_sandbox_config, got {value:?}" + ))), + } + } +} + #[derive(Deserialize, Debug, Clone, PartialEq)] pub struct RemoteSandboxConfigToml { pub hostname_patterns: Vec, @@ -999,13 +1058,12 @@ impl ConfigRequirementsToml { let Some(hostname) = hostname.and_then(normalize_hostname) else { return; }; - let Some(matched_config) = remote_sandbox_config - .iter() - .find(|config| hostname_matches_any_pattern(&hostname, &config.hostname_patterns)) + let Some(allowed_sandbox_modes) = + remote_sandbox_config.matching_allowed_sandbox_modes(&hostname) else { return; }; - self.allowed_sandbox_modes = Some(matched_config.allowed_sandbox_modes.clone()); + self.allowed_sandbox_modes = Some(allowed_sandbox_modes); } pub fn is_empty(&self) -> bool { @@ -2458,13 +2516,16 @@ allowed_approvals_reviewers = ["user"] assert_eq!( config.remote_sandbox_config, - Some(vec![RemoteSandboxConfigToml { - hostname_patterns: vec!["*.org".to_string(), "runner-??.ci".to_string()], - allowed_sandbox_modes: vec![ - SandboxModeRequirement::ReadOnly, - SandboxModeRequirement::WorkspaceWrite, - ], - }]) + Some( + vec![RemoteSandboxConfigToml { + hostname_patterns: vec!["*.org".to_string(), "runner-??.ci".to_string()], + allowed_sandbox_modes: vec![ + SandboxModeRequirement::ReadOnly, + SandboxModeRequirement::WorkspaceWrite, + ], + }] + .into() + ) ); let err = from_str::( @@ -2484,17 +2545,63 @@ allowed_approvals_reviewers = ["user"] } #[test] - fn remote_sandbox_config_first_match_overrides_top_level() -> Result<()> { + fn deserialize_remote_sandbox_config_accepts_keyed_map() -> Result<()> { + let config: ConfigRequirementsToml = from_str( + r#" + [remote_sandbox_config.devboxes] + hostname_patterns = ["*.dev.example.com", "devbox-*"] + allowed_sandbox_modes = ["read-only", "workspace-write"] + + [remote_sandbox_config.ci] + hostname_patterns = ["runner-*.ci.example.com", "*.ci.example.com"] + allowed_sandbox_modes = ["read-only", "danger-full-access"] + "#, + )?; + + assert_eq!( + config.remote_sandbox_config, + Some( + vec![ + RemoteSandboxConfigToml { + hostname_patterns: vec![ + "*.dev.example.com".to_string(), + "devbox-*".to_string(), + ], + allowed_sandbox_modes: vec![ + SandboxModeRequirement::ReadOnly, + SandboxModeRequirement::WorkspaceWrite, + ], + }, + RemoteSandboxConfigToml { + hostname_patterns: vec![ + "runner-*.ci.example.com".to_string(), + "*.ci.example.com".to_string(), + ], + allowed_sandbox_modes: vec![ + SandboxModeRequirement::ReadOnly, + SandboxModeRequirement::DangerFullAccess, + ], + }, + ] + .into() + ) + ); + + Ok(()) + } + + #[test] + fn remote_sandbox_config_matching_entries_intersect_and_override_top_level() -> Result<()> { let source = RequirementSource::CloudRequirements; let mut requirements_toml: ConfigRequirementsToml = from_str( r#" allowed_sandbox_modes = ["read-only"] - [[remote_sandbox_config]] + [remote_sandbox_config.build] hostname_patterns = ["build-*.example.com"] allowed_sandbox_modes = ["read-only", "workspace-write"] - [[remote_sandbox_config]] + [remote_sandbox_config.build_01] hostname_patterns = ["build-01.example.com"] allowed_sandbox_modes = ["read-only", "danger-full-access"] "#, @@ -2508,34 +2615,18 @@ allowed_approvals_reviewers = ["user"] .allowed_sandbox_modes .as_ref() .map(|sourced| sourced.value.clone()), - Some(vec![ - SandboxModeRequirement::ReadOnly, - SandboxModeRequirement::WorkspaceWrite, - ]) + Some(vec![SandboxModeRequirement::ReadOnly]) ); let requirements = ConfigRequirements::try_from(requirements_with_sources)?; - let root = if cfg!(windows) { "C:\\repo" } else { "/repo" }; - let workspace_write_profile = PermissionProfile::workspace_write_with( - &[AbsolutePathBuf::from_absolute_path(root)?], - NetworkSandboxPolicy::Restricted, - /*exclude_tmpdir_env_var*/ false, - /*exclude_slash_tmp*/ false, - ); - assert!( - requirements - .permission_profile - .can_set(&workspace_write_profile) - .is_ok() - ); assert_eq!( requirements .permission_profile - .can_set(&PermissionProfile::Disabled), + .can_set(&PermissionProfile::workspace_write()), Err(ConstraintError::InvalidValue { field_name: "sandbox_mode", - candidate: "DangerFullAccess".into(), - allowed: "[ReadOnly, WorkspaceWrite]".into(), + candidate: "WorkspaceWrite".into(), + allowed: "[ReadOnly]".into(), requirement_source: source, }) ); diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index 2682e6ef41..bcdd56a193 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -1802,7 +1802,7 @@ async fn load_config_layers_applies_matching_remote_sandbox_config() -> anyhow:: r#" allowed_sandbox_modes = ["read-only"] - [[remote_sandbox_config]] + [remote_sandbox_config.any_host] hostname_patterns = ["*"] allowed_sandbox_modes = ["read-only", "workspace-write"] "#,