diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index 620b152b9..ebd2042a1 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -1,5 +1,6 @@ use crate::history_cell::PlainHistoryCell; use crate::legacy_core::config::Config; +use crate::legacy_core::config::Permissions; use crate::session_state::SessionNetworkProxyRuntime; use codex_app_server_protocol::ConfigLayerSource; use codex_config::CONFIG_TOML_FILE; @@ -15,6 +16,8 @@ use codex_config::ResidencyRequirement; use codex_config::SandboxModeRequirement; use codex_config::WebSearchModeRequirement; use codex_config::format_config_layer_source; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::NetworkSandboxPolicy; use ratatui::style::Stylize; use ratatui::text::Line; use toml::Value as TomlValue; @@ -23,7 +26,9 @@ pub(crate) fn new_debug_config_output( config: &Config, session_network_proxy: Option<&SessionNetworkProxyRuntime>, ) -> PlainHistoryCell { - let mut lines = render_debug_config_lines(&config.config_layer_stack); + let mut lines = render_debug_config_lines(&config.config_layer_stack, |mode| { + sandbox_mode_is_allowed_by_permissions(&config.permissions, mode) + }); if let Some(proxy) = session_network_proxy { lines.push("".into()); @@ -49,6 +54,24 @@ pub(crate) fn new_debug_config_output( PlainHistoryCell::new(lines) } +fn sandbox_mode_is_allowed_by_permissions( + permissions: &Permissions, + mode: SandboxModeRequirement, +) -> bool { + let permission_profile = match mode { + SandboxModeRequirement::ReadOnly => PermissionProfile::read_only(), + SandboxModeRequirement::WorkspaceWrite => PermissionProfile::workspace_write(), + SandboxModeRequirement::DangerFullAccess => PermissionProfile::Disabled, + SandboxModeRequirement::ExternalSandbox => PermissionProfile::External { + network: NetworkSandboxPolicy::Restricted, + }, + }; + + permissions + .can_set_permission_profile(&permission_profile) + .is_ok() +} + fn session_all_proxy_url(http_addr: &str, socks_addr: &str, socks_enabled: bool) -> String { if socks_enabled { format!("socks5h://{socks_addr}") @@ -57,7 +80,10 @@ fn session_all_proxy_url(http_addr: &str, socks_addr: &str, socks_enabled: bool) } } -fn render_debug_config_lines(stack: &ConfigLayerStack) -> Vec> { +fn render_debug_config_lines( + stack: &ConfigLayerStack, + sandbox_mode_is_effectively_allowed: impl Fn(SandboxModeRequirement) -> bool, +) -> Vec> { let mut lines = vec!["/debug-config".magenta().into(), "".into()]; lines.push( @@ -122,6 +148,7 @@ fn render_debug_config_lines(stack: &ConfigLayerStack) -> Vec> { modes .iter() .copied() + .filter(|mode| sandbox_mode_is_effectively_allowed(*mode)) .map(format_sandbox_mode_requirement) .collect::>(), ); @@ -515,8 +542,11 @@ fn format_network_unix_socket_permission( #[cfg(test)] mod tests { use super::render_debug_config_lines; + use super::sandbox_mode_is_allowed_by_permissions; use super::session_all_proxy_url; use crate::legacy_core::config::Constrained; + use crate::legacy_core::config::ConstraintError; + use crate::legacy_core::config::Permissions; use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::ConfigLayerSource; use codex_config::ConfigLayerEntry; @@ -542,6 +572,7 @@ mod tests { use codex_config::SandboxModeRequirement; use codex_config::Sourced; use codex_config::WebSearchModeRequirement; + use codex_config::sandbox_mode_requirement_for_permission_profile; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::WebSearchMode; use codex_protocol::models::PermissionProfile; @@ -571,6 +602,20 @@ mod tests { .join("\n") } + fn render_stack_to_text(stack: &ConfigLayerStack) -> String { + render_stack_to_text_with_sandbox_mode_filter(stack, |_| true) + } + + fn render_stack_to_text_with_sandbox_mode_filter( + stack: &ConfigLayerStack, + sandbox_mode_is_effectively_allowed: impl Fn(SandboxModeRequirement) -> bool, + ) -> String { + render_to_text(&render_debug_config_lines( + stack, + sandbox_mode_is_effectively_allowed, + )) + } + #[test] fn debug_config_output_lists_all_layers_including_disabled() { let system_file = if cfg!(windows) { @@ -604,7 +649,7 @@ mod tests { ) .expect("config layer stack"); - let rendered = render_to_text(&render_debug_config_lines(&stack)); + let rendered = render_stack_to_text(&stack); assert!(rendered.contains("(enabled)")); assert!(rendered.contains("(disabled)")); assert!(rendered.contains("reason: project is untrusted")); @@ -749,7 +794,7 @@ mod tests { ) .expect("config layer stack"); - let rendered = render_to_text(&render_debug_config_lines(&stack)); + let rendered = render_stack_to_text(&stack); let requirements_source = (RequirementSource::LegacyManagedConfigTomlFromMdm).to_string(); assert!(rendered.contains(&format!( "allowed_approval_policies: on-request (source: {requirements_source})" @@ -800,6 +845,93 @@ mod tests { assert!(!rendered.contains(" - rules:")); } + #[test] + fn debug_config_output_filters_sandbox_modes_blocked_by_deny_read_requirements() { + let requirements_file = if cfg!(windows) { + absolute_path("C:\\ProgramData\\OpenAI\\Codex\\requirements.toml") + } else { + absolute_path("/etc/codex/requirements.toml") + }; + let denied_path = if cfg!(windows) { + absolute_path("C:\\Users\\alice\\.gitconfig") + } else { + absolute_path("/home/alice/.gitconfig") + }; + + let requirements = ConfigRequirements { + permission_profile: ConstrainedWithSource::new( + Constrained::allow_any(PermissionProfile::read_only()), + Some(RequirementSource::SystemRequirementsToml { + file: requirements_file.clone(), + }), + ), + filesystem: Some(Sourced::new( + FilesystemConstraints { + deny_read: vec![denied_path.into()], + }, + RequirementSource::SystemRequirementsToml { + file: requirements_file.clone(), + }, + )), + ..ConfigRequirements::default() + }; + let requirements_toml = ConfigRequirementsToml { + allowed_sandbox_modes: Some(vec![ + SandboxModeRequirement::ReadOnly, + SandboxModeRequirement::WorkspaceWrite, + SandboxModeRequirement::DangerFullAccess, + SandboxModeRequirement::ExternalSandbox, + ]), + ..ConfigRequirementsToml::default() + }; + let stack = ConfigLayerStack::new(Vec::new(), requirements, requirements_toml) + .expect("config layer stack"); + let constrained_permission_profile = + Constrained::new(PermissionProfile::read_only(), |candidate| { + let mode = sandbox_mode_requirement_for_permission_profile(candidate); + match mode { + SandboxModeRequirement::ReadOnly | SandboxModeRequirement::WorkspaceWrite => { + Ok(()) + } + SandboxModeRequirement::DangerFullAccess + | SandboxModeRequirement::ExternalSandbox => { + Err(ConstraintError::InvalidValue { + field_name: "sandbox_mode", + candidate: format!("{mode:?}"), + allowed: "[read-only, workspace-write]".to_string(), + requirement_source: RequirementSource::Unknown, + }) + } + } + }) + .expect("constrained permission profile"); + let permissions = Permissions::from_approval_and_profile( + Constrained::allow_any(AskForApproval::OnRequest.to_core()), + constrained_permission_profile, + ) + .expect("permissions"); + + let rendered = render_stack_to_text_with_sandbox_mode_filter(&stack, |mode| { + sandbox_mode_is_allowed_by_permissions(&permissions, mode) + }); + #[cfg(not(windows))] + insta::assert_snapshot!( + "debug_config_effective_sandbox_modes_with_deny_read", + rendered.as_str() + ); + assert!( + rendered.contains( + format!( + "allowed_sandbox_modes: read-only, workspace-write (source: {})", + requirements_file.as_path().display() + ) + .as_str() + ) + ); + assert!(!rendered.contains("danger-full-access")); + assert!(!rendered.contains("external-sandbox")); + } + #[test] fn debug_config_output_lists_approvals_reviewer_as_requirement() { let requirements = ConfigRequirements { @@ -816,7 +948,7 @@ mod tests { let stack = ConfigLayerStack::new(Vec::new(), requirements, requirements_toml) .expect("config layer stack"); - let rendered = render_to_text(&render_debug_config_lines(&stack)); + let rendered = render_stack_to_text(&stack); assert!(rendered.contains( "allowed_approvals_reviewers: auto_review (source: MDM managed_config.toml (legacy))" )); @@ -851,7 +983,7 @@ mod tests { ConfigLayerStack::new(Vec::new(), requirements, ConfigRequirementsToml::default()) .expect("config layer stack"); - let rendered = render_to_text(&render_debug_config_lines(&stack)); + let rendered = render_stack_to_text(&stack); let requirements_source = (RequirementSource::LegacyManagedConfigTomlFromMdm).to_string(); assert!(rendered.contains(&format!( "experimental_network: unix_sockets={{/tmp/blocked.sock=deny, /tmp/codex.sock=allow}} (source: {requirements_source})" @@ -880,7 +1012,7 @@ writable_roots = ["/tmp"] ) .expect("config layer stack"); - let rendered = render_to_text(&render_debug_config_lines(&stack)); + let rendered = render_stack_to_text(&stack); assert!(rendered.contains("session-flags (enabled)")); assert!(rendered.contains(" - model = \"gpt-5\"")); assert!(rendered.contains(" - sandbox_workspace_write.network_access = true")); @@ -914,7 +1046,7 @@ approval_policy = "never" ) .expect("config layer stack"); - let rendered = render_to_text(&render_debug_config_lines(&stack)); + let rendered = render_stack_to_text(&stack); assert!(rendered.contains("legacy managed_config.toml (MDM) (enabled)")); assert!(rendered.contains("MDM value:")); assert!(rendered.contains("# managed by MDM")); @@ -951,7 +1083,7 @@ approval_policy = "never" ) .expect("config layer stack"); - let rendered = render_to_text(&render_debug_config_lines(&stack)); + let rendered = render_stack_to_text(&stack); assert!(rendered.contains("enterprise-managed (Base policy, cfg_123) (enabled)")); assert!(rendered.contains("Enterprise-managed config value:")); assert!(!rendered.contains("MDM value:")); @@ -997,7 +1129,7 @@ approval_policy = "never" let stack = ConfigLayerStack::new(Vec::new(), requirements, requirements_toml) .expect("config layer stack"); - let rendered = render_to_text(&render_debug_config_lines(&stack)); + let rendered = render_stack_to_text(&stack); let requirements_source = (RequirementSource::LegacyManagedConfigTomlFromMdm).to_string(); assert!(rendered.contains(&format!( "allowed_web_search_modes: disabled (source: {requirements_source})" @@ -1043,7 +1175,7 @@ approval_policy = "never" let stack = ConfigLayerStack::new(Vec::new(), requirements, requirements_toml) .expect("config layer stack"); - let rendered = render_to_text(&render_debug_config_lines(&stack)); + let rendered = render_stack_to_text(&stack); let requirements_source = (RequirementSource::LegacyManagedConfigTomlFromMdm).to_string(); assert!(rendered.contains("hooks:")); assert!(rendered.contains("handlers=1")); diff --git a/codex-rs/tui/src/snapshots/codex_tui__debug_config__tests__debug_config_effective_sandbox_modes_with_deny_read.snap b/codex-rs/tui/src/snapshots/codex_tui__debug_config__tests__debug_config_effective_sandbox_modes_with_deny_read.snap new file mode 100644 index 000000000..b4ccc63a9 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__debug_config__tests__debug_config_effective_sandbox_modes_with_deny_read.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/debug_config.rs +expression: rendered.as_str() +--- +/debug-config + +Config layer stack (lowest precedence first): + + +Requirements: + - allowed_sandbox_modes: read-only, workspace-write (source: /etc/codex/requirements.toml) + - permissions.filesystem.deny_read: /home/alice/.gitconfig (source: /etc/codex/requirements.toml)