diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 88c0e7dd60..8c48436b6e 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1186,10 +1186,22 @@ impl CodexMessageProcessor { arg0: None, }; - let effective_policy = params - .sandbox_policy - .map(|policy| policy.to_core()) - .unwrap_or_else(|| self.config.sandbox_policy.clone()); + let requested_policy = params.sandbox_policy.map(|policy| policy.to_core()); + let effective_policy = match requested_policy { + Some(policy) => match self.config.sandbox_policy.can_set(&policy) { + Ok(()) => policy, + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("invalid sandbox policy: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }, + None => self.config.sandbox_policy.get().clone(), + }; let codex_linux_sandbox_exe = self.config.codex_linux_sandbox_exe.clone(); let outgoing = self.outgoing.clone(); diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 7aeed28fe8..8c1f3e5d39 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -140,7 +140,7 @@ async fn run_command_under_sandbox( use codex_windows_sandbox::run_windows_sandbox_capture; use codex_windows_sandbox::run_windows_sandbox_capture_elevated; - let policy_str = serde_json::to_string(&config.sandbox_policy)?; + let policy_str = serde_json::to_string(config.sandbox_policy.get())?; let sandbox_cwd = sandbox_policy_cwd.clone(); let cwd_clone = cwd.clone(); @@ -216,7 +216,7 @@ async fn run_command_under_sandbox( spawn_command_under_seatbelt( command, cwd, - &config.sandbox_policy, + config.sandbox_policy.get(), sandbox_policy_cwd.as_path(), stdio_policy, env, @@ -232,7 +232,7 @@ async fn run_command_under_sandbox( codex_linux_sandbox_exe, command, cwd, - &config.sandbox_policy, + config.sandbox_policy.get(), sandbox_policy_cwd.as_path(), stdio_policy, env, diff --git a/codex-rs/common/src/config_summary.rs b/codex-rs/common/src/config_summary.rs index 2254eeae85..1eeabfb533 100644 --- a/codex-rs/common/src/config_summary.rs +++ b/codex-rs/common/src/config_summary.rs @@ -10,7 +10,10 @@ pub fn create_config_summary_entries(config: &Config, model: &str) -> Vec<(&'sta ("model", model.to_string()), ("provider", config.model_provider_id.clone()), ("approval", config.approval_policy.value().to_string()), - ("sandbox", summarize_sandbox_policy(&config.sandbox_policy)), + ( + "sandbox", + summarize_sandbox_policy(config.sandbox_policy.get()), + ), ]; if config.model_provider.wire_api == WireApi::Responses { let reasoning_effort = config diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 440135f7fd..a659edc77d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -415,7 +415,7 @@ pub(crate) struct SessionConfiguration { /// When to escalate for approval for execution approval_policy: Constrained, /// How to sandbox commands executed in the system - sandbox_policy: SandboxPolicy, + sandbox_policy: Constrained, /// Working directory that should be treated as the *root* of the /// session. All relative paths supplied by the model as well as the @@ -451,7 +451,7 @@ impl SessionConfiguration { next_configuration.approval_policy.set(approval_policy)?; } if let Some(sandbox_policy) = updates.sandbox_policy.clone() { - next_configuration.sandbox_policy = sandbox_policy; + next_configuration.sandbox_policy.set(sandbox_policy)?; } if let Some(cwd) = updates.cwd.clone() { next_configuration.cwd = cwd; @@ -526,7 +526,7 @@ impl Session { compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), approval_policy: session_configuration.approval_policy.value(), - sandbox_policy: session_configuration.sandbox_policy.clone(), + sandbox_policy: session_configuration.sandbox_policy.get().clone(), shell_environment_policy: per_turn_config.shell_environment_policy.clone(), tools_config, ghost_snapshot: per_turn_config.ghost_snapshot.clone(), @@ -643,7 +643,7 @@ impl Session { config.model_context_window, config.model_auto_compact_token_limit, config.approval_policy.value(), - config.sandbox_policy.clone(), + config.sandbox_policy.get().clone(), config.mcp_servers.keys().map(String::as_str).collect(), config.active_profile.clone(), ); @@ -693,7 +693,7 @@ impl Session { model: session_configuration.model.clone(), model_provider_id: config.model_provider_id.clone(), approval_policy: session_configuration.approval_policy.value(), - sandbox_policy: session_configuration.sandbox_policy.clone(), + sandbox_policy: session_configuration.sandbox_policy.get().clone(), cwd: session_configuration.cwd.clone(), reasoning_effort: session_configuration.model_reasoning_effort, history_log_id, @@ -710,7 +710,7 @@ impl Session { // Construct sandbox_state before initialize() so it can be sent to each // MCP server immediately after it becomes ready (avoiding blocking). let sandbox_state = SandboxState { - sandbox_policy: session_configuration.sandbox_policy.clone(), + sandbox_policy: session_configuration.sandbox_policy.get().clone(), codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), sandbox_cwd: session_configuration.cwd.clone(), }; @@ -891,7 +891,7 @@ impl Session { if sandbox_policy_changed { let sandbox_state = SandboxState { - sandbox_policy: per_turn_config.sandbox_policy.clone(), + sandbox_policy: per_turn_config.sandbox_policy.get().clone(), codex_linux_sandbox_exe: per_turn_config.codex_linux_sandbox_exe.clone(), sandbox_cwd: per_turn_config.cwd.clone(), }; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index c958bcabbe..986e9eb91a 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -113,7 +113,7 @@ pub struct Config { /// Approval policy for executing commands. pub approval_policy: Constrained, - pub sandbox_policy: SandboxPolicy, + pub sandbox_policy: Constrained, /// True if the user passed in an override or set a value in config.toml /// for either of approval_policy or sandbox_mode. @@ -1235,11 +1235,15 @@ impl Config { // Config. let ConfigRequirements { approval_policy: mut constrained_approval_policy, + sandbox_policy: mut constrained_sandbox_policy, } = requirements; constrained_approval_policy .set(approval_policy) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{e}")))?; + constrained_sandbox_policy + .set(sandbox_policy) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{e}")))?; let config = Self { model, @@ -1250,7 +1254,7 @@ impl Config { model_provider, cwd: resolved_cwd, approval_policy: constrained_approval_policy, - sandbox_policy, + sandbox_policy: constrained_sandbox_policy, did_user_set_custom_approval_policy_or_sandbox_mode, forced_auto_mode_downgraded_on_windows, shell_environment_policy, @@ -1672,12 +1676,12 @@ trust_level = "trusted" config.forced_auto_mode_downgraded_on_windows, "expected workspace-write request to be downgraded on Windows" ); - match config.sandbox_policy { - SandboxPolicy::ReadOnly => {} + match config.sandbox_policy.get() { + &SandboxPolicy::ReadOnly => {} other => panic!("expected read-only policy on Windows, got {other:?}"), } } else { - match config.sandbox_policy { + match config.sandbox_policy.get() { SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { assert_eq!( writable_roots @@ -1809,8 +1813,8 @@ trust_level = "trusted" )?; assert!(matches!( - config.sandbox_policy, - SandboxPolicy::DangerFullAccess + config.sandbox_policy.get(), + &SandboxPolicy::DangerFullAccess )); assert!(config.did_user_set_custom_approval_policy_or_sandbox_mode); @@ -1846,11 +1850,14 @@ trust_level = "trusted" )?; if cfg!(target_os = "windows") { - assert!(matches!(config.sandbox_policy, SandboxPolicy::ReadOnly)); + assert!(matches!( + config.sandbox_policy.get(), + SandboxPolicy::ReadOnly + )); assert!(config.forced_auto_mode_downgraded_on_windows); } else { assert!(matches!( - config.sandbox_policy, + config.sandbox_policy.get(), SandboxPolicy::WorkspaceWrite { .. } )); assert!(!config.forced_auto_mode_downgraded_on_windows); @@ -3048,7 +3055,7 @@ model_verbosity = "high" model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), approval_policy: Constrained::allow_any(AskForApproval::Never), - sandbox_policy: SandboxPolicy::new_read_only_policy(), + sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), did_user_set_custom_approval_policy_or_sandbox_mode: true, forced_auto_mode_downgraded_on_windows: false, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -3123,7 +3130,7 @@ model_verbosity = "high" model_provider_id: "openai-chat-completions".to_string(), model_provider: fixture.openai_chat_completions_provider.clone(), approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted), - sandbox_policy: SandboxPolicy::new_read_only_policy(), + sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), did_user_set_custom_approval_policy_or_sandbox_mode: true, forced_auto_mode_downgraded_on_windows: false, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -3213,7 +3220,7 @@ model_verbosity = "high" model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), approval_policy: Constrained::allow_any(AskForApproval::OnFailure), - sandbox_policy: SandboxPolicy::new_read_only_policy(), + sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), did_user_set_custom_approval_policy_or_sandbox_mode: true, forced_auto_mode_downgraded_on_windows: false, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -3289,7 +3296,7 @@ model_verbosity = "high" model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), approval_policy: Constrained::allow_any(AskForApproval::OnFailure), - sandbox_policy: SandboxPolicy::new_read_only_policy(), + sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), did_user_set_custom_approval_policy_or_sandbox_mode: true, forced_auto_mode_downgraded_on_windows: false, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -3634,12 +3641,15 @@ trust_level = "untrusted" // Verify that untrusted projects still get WorkspaceWrite sandbox (or ReadOnly on Windows) if cfg!(target_os = "windows") { assert!( - matches!(config.sandbox_policy, SandboxPolicy::ReadOnly), + matches!(config.sandbox_policy.get(), SandboxPolicy::ReadOnly), "Expected ReadOnly on Windows" ); } else { assert!( - matches!(config.sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }), + matches!( + config.sandbox_policy.get(), + SandboxPolicy::WorkspaceWrite { .. } + ), "Expected WorkspaceWrite sandbox for untrusted project" ); } diff --git a/codex-rs/core/src/config_loader/config_requirements.rs b/codex-rs/core/src/config_loader/config_requirements.rs index f611b31ff0..feb854df69 100644 --- a/codex-rs/core/src/config_loader/config_requirements.rs +++ b/codex-rs/core/src/config_loader/config_requirements.rs @@ -1,4 +1,6 @@ +use codex_protocol::config_types::SandboxMode; use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use serde::Deserialize; use crate::config::Constrained; @@ -9,12 +11,14 @@ use crate::config::ConstraintError; #[derive(Debug, Clone, PartialEq)] pub struct ConfigRequirements { pub approval_policy: Constrained, + pub sandbox_policy: Constrained, } impl Default for ConfigRequirements { fn default() -> Self { Self { approval_policy: Constrained::allow_any_from_default(), + sandbox_policy: Constrained::allow_any(SandboxPolicy::ReadOnly), } } } @@ -23,6 +27,34 @@ impl Default for ConfigRequirements { #[derive(Deserialize, Debug, Clone, Default, PartialEq)] pub struct ConfigRequirementsToml { pub allowed_approval_policies: Option>, + pub allowed_sandbox_modes: Option>, +} + +/// Currently, `external-sandbox` is not supported in config.toml, but it is +/// supported through programmatic use. +#[derive(Deserialize, Debug, Clone, Copy, PartialEq)] +pub enum SandboxModeRequirement { + #[serde(rename = "read-only")] + ReadOnly, + + #[serde(rename = "workspace-write")] + WorkspaceWrite, + + #[serde(rename = "danger-full-access")] + DangerFullAccess, + + #[serde(rename = "external-sandbox")] + ExternalSandbox, +} + +impl From for SandboxModeRequirement { + fn from(mode: SandboxMode) -> Self { + match mode { + SandboxMode::ReadOnly => SandboxModeRequirement::ReadOnly, + SandboxMode::WorkspaceWrite => SandboxModeRequirement::WorkspaceWrite, + SandboxMode::DangerFullAccess => SandboxModeRequirement::DangerFullAccess, + } + } } impl ConfigRequirementsToml { @@ -41,7 +73,7 @@ impl ConfigRequirementsToml { }; } - fill_missing_take!(self, other, { allowed_approval_policies }); + fill_missing_take!(self, other, { allowed_approval_policies, allowed_sandbox_modes }); } } @@ -49,12 +81,13 @@ impl TryFrom for ConfigRequirements { type Error = ConstraintError; fn try_from(toml: ConfigRequirementsToml) -> Result { - let approval_policy: Constrained = match toml.allowed_approval_policies { + let ConfigRequirementsToml { + allowed_approval_policies, + allowed_sandbox_modes, + } = toml; + let approval_policy: Constrained = match allowed_approval_policies { Some(policies) => { - let default_value = AskForApproval::default(); - if policies.contains(&default_value) { - Constrained::allow_values(default_value, policies)? - } else if let Some(first) = policies.first() { + if let Some(first) = policies.first() { Constrained::allow_values(*first, policies)? } else { return Err(ConstraintError::empty_field("allowed_approval_policies")); @@ -62,7 +95,51 @@ impl TryFrom for ConfigRequirements { } None => Constrained::allow_any_from_default(), }; - Ok(ConfigRequirements { approval_policy }) + + // TODO(gt): `ConfigRequirementsToml` should let the author specify the + // default `SandboxPolicy`? Should do this for `AskForApproval` too? + // + // Currently, we force ReadOnly as the default policy because two of + // the other variants (WorkspaceWrite, ExternalSandbox) require + // additional parameters. Ultimately, we should expand the config + // format to allow specifying those parameters. + let default_sandbox_policy = SandboxPolicy::ReadOnly; + let sandbox_policy: Constrained = match allowed_sandbox_modes { + Some(modes) => { + if !modes.contains(&SandboxModeRequirement::ReadOnly) { + return Err(ConstraintError::invalid_value( + "allowed_sandbox_modes", + "must include 'read-only' to allow any SandboxPolicy", + )); + }; + + Constrained::new(default_sandbox_policy, move |candidate| { + let mode = match candidate { + SandboxPolicy::ReadOnly => SandboxModeRequirement::ReadOnly, + SandboxPolicy::WorkspaceWrite { .. } => { + SandboxModeRequirement::WorkspaceWrite + } + SandboxPolicy::DangerFullAccess => SandboxModeRequirement::DangerFullAccess, + SandboxPolicy::ExternalSandbox { .. } => { + SandboxModeRequirement::ExternalSandbox + } + }; + if modes.contains(&mode) { + Ok(()) + } else { + Err(ConstraintError::invalid_value( + format!("{candidate:?}"), + format!("{modes:?}"), + )) + } + })? + } + None => Constrained::allow_any(default_sandbox_policy), + }; + Ok(ConfigRequirements { + approval_policy, + sandbox_policy, + }) } } @@ -70,6 +147,8 @@ impl TryFrom for ConfigRequirements { mod tests { use super::*; use anyhow::Result; + use codex_protocol::protocol::NetworkAccess; + use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use toml::from_str; @@ -104,4 +183,105 @@ mod tests { ); Ok(()) } + + #[test] + fn deserialize_allowed_approval_policies() -> Result<()> { + let toml_str = r#" + allowed_approval_policies = ["untrusted", "on-request"] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let requirements = ConfigRequirements::try_from(config)?; + + assert_eq!( + requirements.approval_policy.value(), + AskForApproval::UnlessTrusted, + "currently, there is no way to specify the default value for approval policy in the toml, so it picks the first allowed value" + ); + assert!( + requirements + .approval_policy + .can_set(&AskForApproval::UnlessTrusted) + .is_ok() + ); + assert_eq!( + requirements + .approval_policy + .can_set(&AskForApproval::OnFailure), + Err(ConstraintError::InvalidValue { + candidate: "OnFailure".into(), + allowed: "[UnlessTrusted, OnRequest]".into(), + }) + ); + assert!( + requirements + .approval_policy + .can_set(&AskForApproval::OnRequest) + .is_ok() + ); + assert_eq!( + requirements.approval_policy.can_set(&AskForApproval::Never), + Err(ConstraintError::InvalidValue { + candidate: "Never".into(), + allowed: "[UnlessTrusted, OnRequest]".into(), + }) + ); + assert!( + requirements + .sandbox_policy + .can_set(&SandboxPolicy::ReadOnly) + .is_ok() + ); + + Ok(()) + } + + #[test] + fn deserialize_allowed_sandbox_modes() -> Result<()> { + let toml_str = r#" + allowed_sandbox_modes = ["read-only", "workspace-write"] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let requirements = ConfigRequirements::try_from(config)?; + + let root = if cfg!(windows) { "C:\\repo" } else { "/repo" }; + assert!( + requirements + .sandbox_policy + .can_set(&SandboxPolicy::ReadOnly) + .is_ok() + ); + assert!( + requirements + .sandbox_policy + .can_set(&SandboxPolicy::WorkspaceWrite { + writable_roots: vec![AbsolutePathBuf::from_absolute_path(root)?], + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }) + .is_ok() + ); + assert_eq!( + requirements + .sandbox_policy + .can_set(&SandboxPolicy::DangerFullAccess), + Err(ConstraintError::InvalidValue { + candidate: "DangerFullAccess".into(), + allowed: "[ReadOnly, WorkspaceWrite]".into(), + }) + ); + assert_eq!( + requirements + .sandbox_policy + .can_set(&SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Restricted, + }), + Err(ConstraintError::InvalidValue { + candidate: "ExternalSandbox { network_access: Restricted }".into(), + allowed: "[ReadOnly, WorkspaceWrite]".into(), + }) + ); + + Ok(()) + } } diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 85d4014a6d..db633de5d7 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -14,6 +14,7 @@ use crate::config::CONFIG_TOML_FILE; use crate::config_loader::config_requirements::ConfigRequirementsToml; use crate::config_loader::layer_io::LoadedConfigLayers; use codex_app_server_protocol::ConfigLayerSource; +use codex_protocol::config_types::SandboxMode; use codex_protocol::protocol::AskForApproval; use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; @@ -238,17 +239,23 @@ async fn load_requirements_from_legacy_scheme( #[derive(Deserialize, Debug, Clone, Default, PartialEq)] struct LegacyManagedConfigToml { approval_policy: Option, + sandbox_mode: Option, } impl From for ConfigRequirementsToml { fn from(legacy: LegacyManagedConfigToml) -> Self { let mut config_requirements_toml = ConfigRequirementsToml::default(); - let LegacyManagedConfigToml { approval_policy } = legacy; + let LegacyManagedConfigToml { + approval_policy, + sandbox_mode, + } = legacy; if let Some(approval_policy) = approval_policy { config_requirements_toml.allowed_approval_policies = Some(vec![approval_policy]); } - + if let Some(sandbox_mode) = sandbox_mode { + config_requirements_toml.allowed_sandbox_modes = Some(vec![sandbox_mode.into()]); + } config_requirements_toml } } diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index fdd97eb676..6e376bbb2b 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -176,7 +176,7 @@ allowed_approval_policies = ["never", "on-request"] let config_requirements: ConfigRequirements = config_requirements_toml.try_into()?; assert_eq!( config_requirements.approval_policy.value(), - AskForApproval::OnRequest + AskForApproval::Never ); config_requirements .approval_policy diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index c228680091..74e38534bd 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -1464,7 +1464,7 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> { let mut builder = test_codex().with_model(model).with_config(move |config| { config.approval_policy = Constrained::allow_any(approval_policy); - config.sandbox_policy = sandbox_policy.clone(); + config.sandbox_policy = Constrained::allow_any(sandbox_policy.clone()); for feature in features { config.features.enable(feature); } @@ -1570,7 +1570,7 @@ async fn approving_execpolicy_amendment_persists_policy_and_skips_future_prompts let sandbox_policy_for_config = sandbox_policy.clone(); let mut builder = test_codex().with_config(move |config| { config.approval_policy = Constrained::allow_any(approval_policy); - config.sandbox_policy = sandbox_policy_for_config; + config.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config); }); let test = builder.build(&server).await?; let allow_prefix_path = test.cwd.path().join("allow-prefix.txt"); diff --git a/codex-rs/core/tests/suite/codex_delegate.rs b/codex-rs/core/tests/suite/codex_delegate.rs index f0c4cb9fe1..b5cd4186a4 100644 --- a/codex-rs/core/tests/suite/codex_delegate.rs +++ b/codex-rs/core/tests/suite/codex_delegate.rs @@ -63,7 +63,7 @@ async fn codex_delegate_forwards_exec_approval_and_proceeds_on_approval() { // routes ExecApprovalRequest via the parent. let mut builder = test_codex().with_model("gpt-5.1").with_config(|config| { config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); - config.sandbox_policy = SandboxPolicy::ReadOnly; + config.sandbox_policy = Constrained::allow_any(SandboxPolicy::ReadOnly); }); let test = builder.build(&server).await.expect("build test codex"); @@ -140,7 +140,7 @@ async fn codex_delegate_forwards_patch_approval_and_proceeds_on_decision() { let mut builder = test_codex().with_model("gpt-5.1").with_config(|config| { config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); // Use a restricted sandbox so patch approval is required - config.sandbox_policy = SandboxPolicy::ReadOnly; + config.sandbox_policy = Constrained::allow_any(SandboxPolicy::ReadOnly); config.include_apply_patch_tool = true; }); let test = builder.build(&server).await.expect("build test codex"); diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index 596cf719b2..e19c41da86 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -935,7 +935,7 @@ async fn handle_container_exec_autoapprove_from_config_records_tool_decision() { let TestCodex { codex, .. } = test_codex() .with_config(|config| { config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); - config.sandbox_policy = SandboxPolicy::DangerFullAccess; + config.sandbox_policy = Constrained::allow_any(SandboxPolicy::DangerFullAccess); }) .build(&server) .await diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index b0b58b8d8c..c21174014d 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -605,7 +605,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a let default_cwd = config.cwd.clone(); let default_approval_policy = config.approval_policy.value(); - let default_sandbox_policy = config.sandbox_policy.clone(); + let default_sandbox_policy = config.sandbox_policy.get(); let default_model = session_configured.model; let default_effort = config.model_reasoning_effort; let default_summary = config.model_reasoning_summary; @@ -695,7 +695,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu let default_cwd = config.cwd.clone(); let default_approval_policy = config.approval_policy.value(); - let default_sandbox_policy = config.sandbox_policy.clone(); + let default_sandbox_policy = config.sandbox_policy.get(); let default_model = session_configured.model; let default_effort = config.model_reasoning_effort; let default_summary = config.model_reasoning_summary; diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 2f02dfd7bb..5369398a31 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -24,7 +24,7 @@ fn resume_history( let turn_ctx = TurnContextItem { cwd: config.cwd.clone(), approval_policy: config.approval_policy.value(), - sandbox_policy: config.sandbox_policy.clone(), + sandbox_policy: config.sandbox_policy.get().clone(), model: previous_model.to_string(), effort: config.model_reasoning_effort, summary: config.model_reasoning_summary, diff --git a/codex-rs/core/tests/suite/tools.rs b/codex-rs/core/tests/suite/tools.rs index 94a08c2d92..7efa8bb28e 100644 --- a/codex-rs/core/tests/suite/tools.rs +++ b/codex-rs/core/tests/suite/tools.rs @@ -415,7 +415,10 @@ async fn shell_timeout_handles_background_grandchild_stdout() -> Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_model("gpt-5.1").with_config(|config| { - config.sandbox_policy = SandboxPolicy::DangerFullAccess; + config + .sandbox_policy + .set(SandboxPolicy::DangerFullAccess) + .expect("set sandbox policy"); }); let test = builder.build(&server).await?; @@ -508,7 +511,9 @@ async fn shell_spawn_failure_truncates_exec_error() -> Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_config(|cfg| { - cfg.sandbox_policy = SandboxPolicy::DangerFullAccess; + cfg.sandbox_policy + .set(SandboxPolicy::DangerFullAccess) + .expect("set sandbox policy"); }); let test = builder.build(&server).await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 8559e30d57..147814b6ce 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -259,7 +259,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any let default_cwd = config.cwd.to_path_buf(); let default_approval_policy = config.approval_policy.value(); - let default_sandbox_policy = config.sandbox_policy.clone(); + let default_sandbox_policy = config.sandbox_policy.get(); let default_effort = config.model_reasoning_effort; let default_summary = config.model_reasoning_summary; @@ -411,7 +411,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any items, cwd: default_cwd, approval_policy: default_approval_policy, - sandbox_policy: default_sandbox_policy, + sandbox_policy: default_sandbox_policy.clone(), model: default_model, effort: default_effort, summary: default_summary, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index fac532f9e3..d03fc71007 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -453,7 +453,7 @@ impl App { { let should_check = codex_core::get_platform_sandbox().is_some() && matches!( - app.config.sandbox_policy, + app.config.sandbox_policy.get(), codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. } | codex_core::protocol::SandboxPolicy::ReadOnly ) @@ -467,7 +467,7 @@ impl App { let env_map: std::collections::HashMap = std::env::vars().collect(); let tx = app.app_event_tx.clone(); let logs_base_dir = app.config.codex_home.clone(); - let sandbox_policy = app.config.sandbox_policy.clone(); + let sandbox_policy = app.config.sandbox_policy.get().clone(); Self::spawn_world_writable_scan(cwd, env_map, logs_base_dir, sandbox_policy, tx); } } @@ -904,19 +904,29 @@ impl App { AppEvent::UpdateSandboxPolicy(policy) => { #[cfg(target_os = "windows")] let policy_is_workspace_write_or_ro = matches!( - policy, + &policy, codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. } | codex_core::protocol::SandboxPolicy::ReadOnly ); - self.config.sandbox_policy = policy.clone(); + if let Err(err) = self.config.sandbox_policy.set(policy.clone()) { + tracing::warn!(%err, "failed to set sandbox policy on app config"); + self.chat_widget + .add_error_message(format!("Failed to set sandbox policy: {err}")); + return Ok(true); + } #[cfg(target_os = "windows")] - if !matches!(policy, codex_core::protocol::SandboxPolicy::ReadOnly) + if !matches!(&policy, codex_core::protocol::SandboxPolicy::ReadOnly) || codex_core::get_platform_sandbox().is_some() { self.config.forced_auto_mode_downgraded_on_windows = false; } - self.chat_widget.set_sandbox_policy(policy); + if let Err(err) = self.chat_widget.set_sandbox_policy(policy) { + tracing::warn!(%err, "failed to set sandbox policy on chat config"); + self.chat_widget + .add_error_message(format!("Failed to set sandbox policy: {err}")); + return Ok(true); + } // If sandbox policy becomes workspace-write or read-only, run the Windows world-writable scan. #[cfg(target_os = "windows")] @@ -936,7 +946,7 @@ impl App { std::env::vars().collect(); let tx = self.app_event_tx.clone(); let logs_base_dir = self.config.codex_home.clone(); - let sandbox_policy = self.config.sandbox_policy.clone(); + let sandbox_policy = self.config.sandbox_policy.get().clone(); Self::spawn_world_writable_scan( cwd, env_map, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 24b111228a..6a312e9327 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -8,6 +8,7 @@ use std::time::Duration; use codex_app_server_protocol::AuthMode; use codex_backend_client::Client as BackendClient; use codex_core::config::Config; +use codex_core::config::ConstraintResult; use codex_core::config::types::Notifications; use codex_core::features::FEATURES; use codex_core::features::Feature; @@ -2725,12 +2726,12 @@ impl ChatWidget { /// Open a popup to choose the approvals mode (ask for approval policy + sandbox policy). pub(crate) fn open_approvals_popup(&mut self) { let current_approval = self.config.approval_policy.value(); - let current_sandbox = self.config.sandbox_policy.clone(); + let current_sandbox = self.config.sandbox_policy.get(); let mut items: Vec = Vec::new(); let presets: Vec = builtin_approval_presets(); for preset in presets.into_iter() { let is_current = - Self::preset_matches_current(current_approval, ¤t_sandbox, &preset); + Self::preset_matches_current(current_approval, current_sandbox, &preset); let name = preset.label.to_string(); let description = Some(preset.description.to_string()); let disabled_reason = match self.config.approval_policy.can_set(&preset.approval) { @@ -2879,7 +2880,7 @@ impl ChatWidget { self.config.codex_home.as_path(), cwd.as_path(), &env_map, - &self.config.sandbox_policy, + self.config.sandbox_policy.get(), Some(self.config.codex_home.as_path()), ) { Ok(_) => None, @@ -2978,7 +2979,7 @@ impl ChatWidget { let mode_label = preset .as_ref() .map(|p| describe_policy(&p.sandbox)) - .unwrap_or_else(|| describe_policy(&self.config.sandbox_policy)); + .unwrap_or_else(|| describe_policy(self.config.sandbox_policy.get())); let info_line = if failed_scan { Line::from(vec![ "We couldn't complete the world-writable scan, so protections cannot be verified. " @@ -3151,17 +3152,19 @@ impl ChatWidget { } /// Set the sandbox policy in the widget's config copy. - pub(crate) fn set_sandbox_policy(&mut self, policy: SandboxPolicy) { + pub(crate) fn set_sandbox_policy(&mut self, policy: SandboxPolicy) -> ConstraintResult<()> { #[cfg(target_os = "windows")] - let should_clear_downgrade = !matches!(policy, SandboxPolicy::ReadOnly) + let should_clear_downgrade = !matches!(&policy, SandboxPolicy::ReadOnly) || codex_core::get_platform_sandbox().is_some(); - self.config.sandbox_policy = policy; + self.config.sandbox_policy.set(policy)?; #[cfg(target_os = "windows")] if should_clear_downgrade { self.config.forced_auto_mode_downgraded_on_windows = false; } + + Ok(()) } pub(crate) fn set_feature_enabled(&mut self, feature: Feature, enabled: bool) { diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 0a86213411..db2b4fa48e 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -217,7 +217,7 @@ pub async fn run_main( let config = load_config_or_exit(cli_kv_overrides.clone(), overrides.clone()).await; - if let Some(warning) = add_dir_warning_message(&cli.add_dir, &config.sandbox_policy) { + if let Some(warning) = add_dir_warning_message(&cli.add_dir, config.sandbox_policy.get()) { #[allow(clippy::print_stderr)] { eprintln!("Error adding directories: {warning}"); diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 2b15d2200f..852efc476e 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -119,7 +119,7 @@ impl StatusHistoryCell { .find(|(k, _)| *k == "approval") .map(|(_, v)| v.clone()) .unwrap_or_else(|| "".to_string()); - let sandbox = match &config.sandbox_policy { + let sandbox = match config.sandbox_policy.get() { SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(), SandboxPolicy::ReadOnly => "read-only".to_string(), SandboxPolicy::WorkspaceWrite { .. } => "workspace-write".to_string(), diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 836c6572e9..893661908c 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -90,12 +90,15 @@ async fn status_snapshot_includes_reasoning_details() { config.model_provider_id = "openai".to_string(); config.model_reasoning_effort = Some(ReasoningEffort::High); config.model_reasoning_summary = ReasoningSummary::Detailed; - config.sandbox_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: Vec::new(), - network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }; + config + .sandbox_policy + .set(SandboxPolicy::WorkspaceWrite { + writable_roots: Vec::new(), + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }) + .expect("set sandbox policy"); config.cwd = PathBuf::from("/workspace/tests"); diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index 0d4ea815ed..3f2ac58998 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -510,7 +510,7 @@ impl App { { let should_check = codex_core::get_platform_sandbox().is_some() && matches!( - app.config.sandbox_policy, + app.config.sandbox_policy.get(), codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. } | codex_core::protocol::SandboxPolicy::ReadOnly ) @@ -524,7 +524,7 @@ impl App { let env_map: std::collections::HashMap = std::env::vars().collect(); let tx = app.app_event_tx.clone(); let logs_base_dir = app.config.codex_home.clone(); - let sandbox_policy = app.config.sandbox_policy.clone(); + let sandbox_policy = app.config.sandbox_policy.get().clone(); Self::spawn_world_writable_scan(cwd, env_map, logs_base_dir, sandbox_policy, tx); } } @@ -1746,19 +1746,29 @@ impl App { AppEvent::UpdateSandboxPolicy(policy) => { #[cfg(target_os = "windows")] let policy_is_workspace_write_or_ro = matches!( - policy, + &policy, codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. } | codex_core::protocol::SandboxPolicy::ReadOnly ); - self.config.sandbox_policy = policy.clone(); + if let Err(err) = self.config.sandbox_policy.set(policy.clone()) { + tracing::warn!(%err, "failed to set sandbox policy on app config"); + self.chat_widget + .add_error_message(format!("Failed to set sandbox policy: {err}")); + return Ok(true); + } #[cfg(target_os = "windows")] - if !matches!(policy, codex_core::protocol::SandboxPolicy::ReadOnly) + if !matches!(&policy, codex_core::protocol::SandboxPolicy::ReadOnly) || codex_core::get_platform_sandbox().is_some() { self.config.forced_auto_mode_downgraded_on_windows = false; } - self.chat_widget.set_sandbox_policy(policy); + if let Err(err) = self.chat_widget.set_sandbox_policy(policy) { + tracing::warn!(%err, "failed to set sandbox policy on chat config"); + self.chat_widget + .add_error_message(format!("Failed to set sandbox policy: {err}")); + return Ok(true); + } // If sandbox policy becomes workspace-write or read-only, run the Windows world-writable scan. #[cfg(target_os = "windows")] @@ -1778,7 +1788,7 @@ impl App { std::env::vars().collect(); let tx = self.app_event_tx.clone(); let logs_base_dir = self.config.codex_home.clone(); - let sandbox_policy = self.config.sandbox_policy.clone(); + let sandbox_policy = self.config.sandbox_policy.get().clone(); Self::spawn_world_writable_scan( cwd, env_map, diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index b7e9b3f567..f8b6bc5a57 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -8,6 +8,7 @@ use std::time::Duration; use codex_app_server_protocol::AuthMode; use codex_backend_client::Client as BackendClient; use codex_core::config::Config; +use codex_core::config::ConstraintResult; use codex_core::config::types::Notifications; use codex_core::git_info::current_branch_name; use codex_core::git_info::local_git_branches; @@ -2554,12 +2555,12 @@ impl ChatWidget { /// Open a popup to choose the approvals mode (ask for approval policy + sandbox policy). pub(crate) fn open_approvals_popup(&mut self) { let current_approval = self.config.approval_policy.value(); - let current_sandbox = self.config.sandbox_policy.clone(); + let current_sandbox = self.config.sandbox_policy.get(); let mut items: Vec = Vec::new(); let presets: Vec = builtin_approval_presets(); for preset in presets.into_iter() { let is_current = - Self::preset_matches_current(current_approval, ¤t_sandbox, &preset); + Self::preset_matches_current(current_approval, current_sandbox, &preset); let name = preset.label.to_string(); let description_text = preset.description; let description = Some(description_text.to_string()); @@ -2685,7 +2686,7 @@ impl ChatWidget { self.config.codex_home.as_path(), cwd.as_path(), &env_map, - &self.config.sandbox_policy, + self.config.sandbox_policy.get(), Some(self.config.codex_home.as_path()), ) { Ok(_) => None, @@ -2784,7 +2785,7 @@ impl ChatWidget { let mode_label = preset .as_ref() .map(|p| describe_policy(&p.sandbox)) - .unwrap_or_else(|| describe_policy(&self.config.sandbox_policy)); + .unwrap_or_else(|| describe_policy(self.config.sandbox_policy.get())); let info_line = if failed_scan { Line::from(vec![ "We couldn't complete the world-writable scan, so protections cannot be verified. " @@ -2957,17 +2958,19 @@ impl ChatWidget { } /// Set the sandbox policy in the widget's config copy. - pub(crate) fn set_sandbox_policy(&mut self, policy: SandboxPolicy) { + pub(crate) fn set_sandbox_policy(&mut self, policy: SandboxPolicy) -> ConstraintResult<()> { #[cfg(target_os = "windows")] - let should_clear_downgrade = !matches!(policy, SandboxPolicy::ReadOnly) + let should_clear_downgrade = !matches!(&policy, SandboxPolicy::ReadOnly) || codex_core::get_platform_sandbox().is_some(); - self.config.sandbox_policy = policy; + self.config.sandbox_policy.set(policy)?; #[cfg(target_os = "windows")] if should_clear_downgrade { self.config.forced_auto_mode_downgraded_on_windows = false; } + + Ok(()) } pub(crate) fn set_full_access_warning_acknowledged(&mut self, acknowledged: bool) { diff --git a/codex-rs/tui2/src/lib.rs b/codex-rs/tui2/src/lib.rs index e05a17721d..dac62abb56 100644 --- a/codex-rs/tui2/src/lib.rs +++ b/codex-rs/tui2/src/lib.rs @@ -223,7 +223,7 @@ pub async fn run_main( let config = load_config_or_exit(cli_kv_overrides.clone(), overrides.clone()).await; - if let Some(warning) = add_dir_warning_message(&cli.add_dir, &config.sandbox_policy) { + if let Some(warning) = add_dir_warning_message(&cli.add_dir, config.sandbox_policy.get()) { #[allow(clippy::print_stderr)] { eprintln!("Error adding directories: {warning}"); diff --git a/codex-rs/tui2/src/status/card.rs b/codex-rs/tui2/src/status/card.rs index 2b15d2200f..852efc476e 100644 --- a/codex-rs/tui2/src/status/card.rs +++ b/codex-rs/tui2/src/status/card.rs @@ -119,7 +119,7 @@ impl StatusHistoryCell { .find(|(k, _)| *k == "approval") .map(|(_, v)| v.clone()) .unwrap_or_else(|| "".to_string()); - let sandbox = match &config.sandbox_policy { + let sandbox = match config.sandbox_policy.get() { SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(), SandboxPolicy::ReadOnly => "read-only".to_string(), SandboxPolicy::WorkspaceWrite { .. } => "workspace-write".to_string(), diff --git a/codex-rs/tui2/src/status/tests.rs b/codex-rs/tui2/src/status/tests.rs index 836c6572e9..893661908c 100644 --- a/codex-rs/tui2/src/status/tests.rs +++ b/codex-rs/tui2/src/status/tests.rs @@ -90,12 +90,15 @@ async fn status_snapshot_includes_reasoning_details() { config.model_provider_id = "openai".to_string(); config.model_reasoning_effort = Some(ReasoningEffort::High); config.model_reasoning_summary = ReasoningSummary::Detailed; - config.sandbox_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: Vec::new(), - network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }; + config + .sandbox_policy + .set(SandboxPolicy::WorkspaceWrite { + writable_roots: Vec::new(), + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }) + .expect("set sandbox policy"); config.cwd = PathBuf::from("/workspace/tests");