From 7134842438e2acdff7f3617a976bf6bebed0cd3e Mon Sep 17 00:00:00 2001 From: Anton Panasenko Date: Wed, 17 Dec 2025 15:00:13 -0800 Subject: [PATCH] feat: configure network_access independent of sandbox policy --- .../app-server-protocol/src/protocol/v1.rs | 2 + .../app-server-protocol/src/protocol/v2.rs | 2 + codex-rs/app-server/tests/suite/config.rs | 2 + codex-rs/core/Cargo.toml | 1 - codex-rs/core/src/codex.rs | 12 ++ codex-rs/core/src/config/mod.rs | 31 ++++ codex-rs/core/src/environment_context.rs | 167 +++++++++++++++--- codex-rs/protocol/src/config_types.rs | 8 + 8 files changed, 197 insertions(+), 28 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index df39f8809e..07ad45518e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use codex_protocol::ConversationId; use codex_protocol::config_types::ForcedLoginMethod; +use codex_protocol::config_types::NetworkAccess; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::Verbosity; @@ -326,6 +327,7 @@ pub struct UserSavedConfig { pub approval_policy: Option, pub sandbox_mode: Option, pub sandbox_settings: Option, + pub network_access: Option, pub forced_chatgpt_workspace_id: Option, pub forced_login_method: Option, pub model: Option, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 19fa0c6633..5680553f35 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -5,6 +5,7 @@ use crate::protocol::common::AuthMode; use codex_protocol::account::PlanType; use codex_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment; use codex_protocol::config_types::ForcedLoginMethod; +use codex_protocol::config_types::NetworkAccess; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode as CoreSandboxMode; use codex_protocol::config_types::Verbosity; @@ -279,6 +280,7 @@ pub struct Config { pub approval_policy: Option, pub sandbox_mode: Option, pub sandbox_workspace_write: Option, + pub network_access: Option, pub forced_chatgpt_workspace_id: Option, pub forced_login_method: Option, pub tools: Option, diff --git a/codex-rs/app-server/tests/suite/config.rs b/codex-rs/app-server/tests/suite/config.rs index 84b268a3c2..7318cb69ce 100644 --- a/codex-rs/app-server/tests/suite/config.rs +++ b/codex-rs/app-server/tests/suite/config.rs @@ -91,6 +91,7 @@ async fn get_config_toml_parses_all_fields() -> Result<()> { exclude_tmpdir_env_var: Some(true), exclude_slash_tmp: Some(true), }), + network_access: None, forced_chatgpt_workspace_id: Some("12345678-0000-0000-0000-000000000000".into()), forced_login_method: Some(ForcedLoginMethod::Chatgpt), model: Some("gpt-5.1-codex-max".into()), @@ -141,6 +142,7 @@ async fn get_config_toml_empty() -> Result<()> { approval_policy: None, sandbox_mode: None, sandbox_settings: None, + network_access: None, forced_chatgpt_workspace_id: None, forced_login_method: None, model: None, diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 0ba8e1fd3b..7a483094c8 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -60,7 +60,6 @@ sha1 = { workspace = true } sha2 = { workspace = true } shlex = { workspace = true } similar = { workspace = true } -strum_macros = { workspace = true } tempfile = { workspace = true } test-case = "3.3.1" test-log = { workspace = true } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 5a0a555e8e..17ac5367a1 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -151,6 +151,7 @@ use crate::util::backoff; use codex_async_utils::OrCancelExt; use codex_execpolicy::Policy as ExecPolicy; use codex_otel::otel_manager::OtelManager; +use codex_protocol::config_types::NetworkAccess; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseInputItem; @@ -266,6 +267,7 @@ impl Codex { compact_prompt: config.compact_prompt.clone(), approval_policy: config.approval_policy.clone(), sandbox_policy: config.sandbox_policy.clone(), + network_access: config.network_access, cwd: config.cwd.clone(), original_config_do_not_use: Arc::clone(&config), exec_policy, @@ -367,6 +369,7 @@ pub(crate) struct TurnContext { pub(crate) user_instructions: Option, pub(crate) approval_policy: AskForApproval, pub(crate) sandbox_policy: SandboxPolicy, + pub(crate) network_access: Option, pub(crate) shell_environment_policy: ShellEnvironmentPolicy, pub(crate) tools_config: ToolsConfig, pub(crate) ghost_snapshot: GhostSnapshotConfig, @@ -418,6 +421,8 @@ pub(crate) struct SessionConfiguration { approval_policy: Constrained, /// How to sandbox commands executed in the system sandbox_policy: SandboxPolicy, + /// Optional override describing network access to include in environment context. + network_access: Option, /// Working directory that should be treated as the *root* of the /// session. All relative paths supplied by the model as well as the @@ -529,6 +534,7 @@ impl Session { user_instructions: session_configuration.user_instructions.clone(), approval_policy: session_configuration.approval_policy.value(), sandbox_policy: session_configuration.sandbox_policy.clone(), + network_access: session_configuration.network_access, shell_environment_policy: per_turn_config.shell_environment_policy.clone(), tools_config, ghost_snapshot: per_turn_config.ghost_snapshot.clone(), @@ -1327,6 +1333,7 @@ impl Session { Some(turn_context.cwd.clone()), Some(turn_context.approval_policy), Some(turn_context.sandbox_policy.clone()), + turn_context.network_access, shell.as_ref().clone(), ))); items @@ -2181,6 +2188,7 @@ async fn spawn_review_thread( compact_prompt: parent_turn_context.compact_prompt.clone(), approval_policy: parent_turn_context.approval_policy, sandbox_policy: parent_turn_context.sandbox_policy.clone(), + network_access: parent_turn_context.network_access, shell_environment_policy: parent_turn_context.shell_environment_policy.clone(), cwd: parent_turn_context.cwd.clone(), final_output_json_schema: None, @@ -2882,6 +2890,7 @@ mod tests { compact_prompt: config.compact_prompt.clone(), approval_policy: config.approval_policy.clone(), sandbox_policy: config.sandbox_policy.clone(), + network_access: config.network_access, cwd: config.cwd.clone(), original_config_do_not_use: Arc::clone(&config), exec_policy: Arc::new(RwLock::new(ExecPolicy::empty())), @@ -2954,6 +2963,7 @@ mod tests { compact_prompt: config.compact_prompt.clone(), approval_policy: config.approval_policy.clone(), sandbox_policy: config.sandbox_policy.clone(), + network_access: config.network_access, cwd: config.cwd.clone(), original_config_do_not_use: Arc::clone(&config), exec_policy: Arc::new(RwLock::new(ExecPolicy::empty())), @@ -3158,6 +3168,7 @@ mod tests { compact_prompt: config.compact_prompt.clone(), approval_policy: config.approval_policy.clone(), sandbox_policy: config.sandbox_policy.clone(), + network_access: config.network_access, cwd: config.cwd.clone(), original_config_do_not_use: Arc::clone(&config), exec_policy: Arc::new(RwLock::new(ExecPolicy::empty())), @@ -3249,6 +3260,7 @@ mod tests { compact_prompt: config.compact_prompt.clone(), approval_policy: config.approval_policy.clone(), sandbox_policy: config.sandbox_policy.clone(), + network_access: config.network_access, cwd: config.cwd.clone(), original_config_do_not_use: Arc::clone(&config), exec_policy: Arc::new(RwLock::new(ExecPolicy::empty())), diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 9a1ad16e94..2ced0d265a 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -29,6 +29,7 @@ use crate::protocol::SandboxPolicy; use codex_app_server_protocol::Tools; use codex_app_server_protocol::UserSavedConfig; use codex_protocol::config_types::ForcedLoginMethod; +use codex_protocol::config_types::NetworkAccess; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::TrustLevel; @@ -113,6 +114,9 @@ pub struct Config { pub sandbox_policy: SandboxPolicy, + /// Optional override for the network status sent in the environment context. + pub network_access: Option, + /// True if the user passed in an override or set a value in config.toml /// for either of approval_policy or sandbox_mode. pub did_user_set_custom_approval_policy_or_sandbox_mode: bool, @@ -560,6 +564,9 @@ pub struct ConfigToml { /// Sandbox mode to use. pub sandbox_mode: Option, + /// Optional override describing network access to include in environment context. + pub network_access: Option, + /// Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`. pub sandbox_workspace_write: Option, @@ -712,6 +719,7 @@ impl From for UserSavedConfig { approval_policy: config_toml.approval_policy, sandbox_mode: config_toml.sandbox_mode, sandbox_settings: config_toml.sandbox_workspace_write.map(From::from), + network_access: config_toml.network_access, forced_chatgpt_workspace_id: config_toml.forced_chatgpt_workspace_id, forced_login_method: config_toml.forced_login_method, model: config_toml.model, @@ -1026,6 +1034,7 @@ impl Config { } } } + let network_access = cfg.network_access; let approval_policy = approval_policy_override .or(config_profile.approval_policy) .or(cfg.approval_policy) @@ -1165,6 +1174,7 @@ impl Config { cwd: resolved_cwd, approval_policy, sandbox_policy, + network_access, did_user_set_custom_approval_policy_or_sandbox_mode, forced_auto_mode_downgraded_on_windows, shell_environment_policy, @@ -1559,6 +1569,23 @@ trust_level = "trusted" } } + #[test] + fn network_access_override_is_loaded() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + network_access: Some(NetworkAccess::Restricted), + ..Default::default() + }, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert_eq!(config.network_access, Some(NetworkAccess::Restricted)); + + Ok(()) + } + #[test] fn add_dir_override_extends_workspace_writable_roots() -> std::io::Result<()> { let temp_dir = TempDir::new()?; @@ -2956,6 +2983,7 @@ model_verbosity = "high" model_provider: fixture.openai_provider.clone(), approval_policy: Constrained::allow_any(AskForApproval::Never), sandbox_policy: SandboxPolicy::new_read_only_policy(), + network_access: None, did_user_set_custom_approval_policy_or_sandbox_mode: true, forced_auto_mode_downgraded_on_windows: false, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -3031,6 +3059,7 @@ model_verbosity = "high" model_provider: fixture.openai_chat_completions_provider.clone(), approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted), sandbox_policy: SandboxPolicy::new_read_only_policy(), + network_access: None, did_user_set_custom_approval_policy_or_sandbox_mode: true, forced_auto_mode_downgraded_on_windows: false, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -3121,6 +3150,7 @@ model_verbosity = "high" model_provider: fixture.openai_provider.clone(), approval_policy: Constrained::allow_any(AskForApproval::OnFailure), sandbox_policy: SandboxPolicy::new_read_only_policy(), + network_access: None, did_user_set_custom_approval_policy_or_sandbox_mode: true, forced_auto_mode_downgraded_on_windows: false, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -3197,6 +3227,7 @@ model_verbosity = "high" model_provider: fixture.openai_provider.clone(), approval_policy: Constrained::allow_any(AskForApproval::OnFailure), sandbox_policy: SandboxPolicy::new_read_only_policy(), + network_access: None, did_user_set_custom_approval_policy_or_sandbox_mode: true, forced_auto_mode_downgraded_on_windows: false, shell_environment_policy: ShellEnvironmentPolicy::default(), diff --git a/codex-rs/core/src/environment_context.rs b/codex-rs/core/src/environment_context.rs index fc4ae174df..c82d78bc71 100644 --- a/codex-rs/core/src/environment_context.rs +++ b/codex-rs/core/src/environment_context.rs @@ -1,12 +1,12 @@ use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; use serde::Serialize; -use strum_macros::Display as DeriveDisplay; use crate::codex::TurnContext; use crate::protocol::AskForApproval; use crate::protocol::SandboxPolicy; use crate::shell::Shell; +use codex_protocol::config_types::NetworkAccess; use codex_protocol::config_types::SandboxMode; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; @@ -14,13 +14,6 @@ use codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG; use codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; use std::path::PathBuf; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, DeriveDisplay)] -#[serde(rename_all = "kebab-case")] -#[strum(serialize_all = "kebab-case")] -pub enum NetworkAccess { - Restricted, - Enabled, -} #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename = "environment_context", rename_all = "snake_case")] pub(crate) struct EnvironmentContext { @@ -37,29 +30,21 @@ impl EnvironmentContext { cwd: Option, approval_policy: Option, sandbox_policy: Option, + network_access: Option, shell: Shell, ) -> Self { + let sandbox_mode = sandbox_policy.as_ref().map(|policy| match policy { + SandboxPolicy::DangerFullAccess => SandboxMode::DangerFullAccess, + SandboxPolicy::ReadOnly => SandboxMode::ReadOnly, + SandboxPolicy::WorkspaceWrite { .. } => SandboxMode::WorkspaceWrite, + }); + let resolved_network_access = + Self::resolve_network_access(sandbox_policy.as_ref(), network_access); Self { cwd, approval_policy, - sandbox_mode: match sandbox_policy { - Some(SandboxPolicy::DangerFullAccess) => Some(SandboxMode::DangerFullAccess), - Some(SandboxPolicy::ReadOnly) => Some(SandboxMode::ReadOnly), - Some(SandboxPolicy::WorkspaceWrite { .. }) => Some(SandboxMode::WorkspaceWrite), - None => None, - }, - network_access: match sandbox_policy { - Some(SandboxPolicy::DangerFullAccess) => Some(NetworkAccess::Enabled), - Some(SandboxPolicy::ReadOnly) => Some(NetworkAccess::Restricted), - Some(SandboxPolicy::WorkspaceWrite { network_access, .. }) => { - if network_access { - Some(NetworkAccess::Enabled) - } else { - Some(NetworkAccess::Restricted) - } - } - None => None, - }, + sandbox_mode, + network_access: resolved_network_access, writable_roots: match sandbox_policy { Some(SandboxPolicy::WorkspaceWrite { writable_roots, .. }) => { if writable_roots.is_empty() { @@ -74,6 +59,27 @@ impl EnvironmentContext { } } + fn resolve_network_access( + sandbox_policy: Option<&SandboxPolicy>, + network_access_override: Option, + ) -> Option { + match network_access_override { + Some(access) => Some(access), + None => match sandbox_policy { + Some(SandboxPolicy::DangerFullAccess) => Some(NetworkAccess::Enabled), + Some(SandboxPolicy::ReadOnly) => Some(NetworkAccess::Restricted), + Some(SandboxPolicy::WorkspaceWrite { network_access, .. }) => { + if *network_access { + Some(NetworkAccess::Enabled) + } else { + Some(NetworkAccess::Restricted) + } + } + None => None, + }, + } + } + /// Compares two environment contexts, ignoring the shell. Useful when /// comparing turn to turn, since the initial environment_context will /// include the shell, and then it is not configurable from turn to turn. @@ -111,7 +117,26 @@ impl EnvironmentContext { } else { None }; - EnvironmentContext::new(cwd, approval_policy, sandbox_policy, shell.clone()) + let before_network_access = EnvironmentContext::resolve_network_access( + Some(&before.sandbox_policy), + before.network_access, + ); + let after_network_access = EnvironmentContext::resolve_network_access( + Some(&after.sandbox_policy), + after.network_access, + ); + let network_access = if before_network_access != after_network_access { + after_network_access + } else { + None + }; + EnvironmentContext::new( + cwd, + approval_policy, + sandbox_policy, + network_access, + shell.clone(), + ) } pub fn from_turn_context(turn_context: &TurnContext, shell: &Shell) -> Self { @@ -119,6 +144,7 @@ impl EnvironmentContext { Some(turn_context.cwd.clone()), Some(turn_context.approval_policy), Some(turn_context.sandbox_policy.clone()), + turn_context.network_access, shell.clone(), ) } @@ -192,6 +218,7 @@ mod tests { use crate::shell::ShellType; use super::*; + use crate::codex::make_session_and_context; use core_test_support::test_path_buf; use core_test_support::test_tmp_path_buf; use pretty_assertions::assert_eq; @@ -231,6 +258,7 @@ mod tests { vec![cwd_str, writable_root_str], false, )), + None, fake_shell(), ); @@ -253,12 +281,51 @@ mod tests { assert_eq!(context.serialize_to_xml(), expected); } + #[test] + fn serialize_workspace_write_environment_context_with_network_override() { + let cwd = test_path_buf("/repo"); + let writable_root = test_tmp_path_buf(); + let cwd_str = cwd.to_str().expect("cwd is valid utf-8"); + let writable_root_str = writable_root + .to_str() + .expect("writable root is valid utf-8"); + let context = EnvironmentContext::new( + Some(cwd.clone()), + Some(AskForApproval::OnRequest), + Some(workspace_write_policy( + vec![cwd_str, writable_root_str], + false, + )), + Some(NetworkAccess::Enabled), + fake_shell(), + ); + + let expected = format!( + r#" + {cwd} + on-request + workspace-write + enabled + + {cwd} + {writable_root} + + bash +"#, + cwd = cwd.display(), + writable_root = writable_root.display(), + ); + + assert_eq!(context.serialize_to_xml(), expected); + } + #[test] fn serialize_read_only_environment_context() { let context = EnvironmentContext::new( None, Some(AskForApproval::Never), Some(SandboxPolicy::ReadOnly), + None, fake_shell(), ); @@ -278,6 +345,7 @@ mod tests { None, Some(AskForApproval::OnFailure), Some(SandboxPolicy::DangerFullAccess), + None, fake_shell(), ); @@ -291,6 +359,43 @@ mod tests { assert_eq!(context.serialize_to_xml(), expected); } + #[test] + fn serialize_full_access_environment_context_with_override() { + let context = EnvironmentContext::new( + None, + Some(AskForApproval::OnFailure), + Some(SandboxPolicy::DangerFullAccess), + Some(NetworkAccess::Restricted), + fake_shell(), + ); + + let expected = r#" + on-failure + danger-full-access + restricted + bash +"#; + + assert_eq!(context.serialize_to_xml(), expected); + } + + #[test] + fn diff_detects_network_override_changes() { + let (_, mut before) = make_session_and_context(); + let (_, mut after) = make_session_and_context(); + before.sandbox_policy = SandboxPolicy::ReadOnly; + after.sandbox_policy = SandboxPolicy::ReadOnly; + before.network_access = None; + after.network_access = Some(NetworkAccess::Enabled); + + let shell = fake_shell(); + let diff = EnvironmentContext::diff(&before, &after, &shell); + let expected = + EnvironmentContext::new(None, None, None, Some(NetworkAccess::Enabled), shell); + + assert_eq!(diff, expected); + } + #[test] fn equals_except_shell_compares_approval_policy() { // Approval policy @@ -298,12 +403,14 @@ mod tests { Some(PathBuf::from("/repo")), Some(AskForApproval::OnRequest), Some(workspace_write_policy(vec!["/repo"], false)), + None, fake_shell(), ); let context2 = EnvironmentContext::new( Some(PathBuf::from("/repo")), Some(AskForApproval::Never), Some(workspace_write_policy(vec!["/repo"], true)), + None, fake_shell(), ); assert!(!context1.equals_except_shell(&context2)); @@ -315,12 +422,14 @@ mod tests { Some(PathBuf::from("/repo")), Some(AskForApproval::OnRequest), Some(SandboxPolicy::new_read_only_policy()), + None, fake_shell(), ); let context2 = EnvironmentContext::new( Some(PathBuf::from("/repo")), Some(AskForApproval::OnRequest), Some(SandboxPolicy::new_workspace_write_policy()), + None, fake_shell(), ); @@ -333,12 +442,14 @@ mod tests { Some(PathBuf::from("/repo")), Some(AskForApproval::OnRequest), Some(workspace_write_policy(vec!["/repo", "/tmp", "/var"], false)), + None, fake_shell(), ); let context2 = EnvironmentContext::new( Some(PathBuf::from("/repo")), Some(AskForApproval::OnRequest), Some(workspace_write_policy(vec!["/repo", "/tmp"], true)), + None, fake_shell(), ); @@ -351,6 +462,7 @@ mod tests { Some(PathBuf::from("/repo")), Some(AskForApproval::OnRequest), Some(workspace_write_policy(vec!["/repo"], false)), + None, Shell { shell_type: ShellType::Bash, shell_path: "/bin/bash".into(), @@ -361,6 +473,7 @@ mod tests { Some(PathBuf::from("/repo")), Some(AskForApproval::OnRequest), Some(workspace_write_policy(vec!["/repo"], false)), + None, Shell { shell_type: ShellType::Zsh, shell_path: "/bin/zsh".into(), diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index a98ec4e2b2..3d0fc87c3e 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -63,6 +63,14 @@ pub enum SandboxMode { DangerFullAccess, } +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)] +#[serde(rename_all = "kebab-case")] +#[strum(serialize_all = "kebab-case")] +pub enum NetworkAccess { + Restricted, + Enabled, +} + #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")]