From 6c108912eeacabfc82723bf44f8a23f6e2f86585 Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Sun, 16 Aug 2026 20:26:25 +0000 Subject: [PATCH] Honor per-environment shell variable policies (#38902) ## What changed - Carry `ShellEnvironmentPolicy` in each resolved `EnvironmentConfig` and use the selected turn environment's policy for shell commands, user shell tasks, and unified exec. - Infer the policy from the thread configuration when an environment does not provide its own resolved configuration. - Redact the policy from `EnvironmentConfig` debug output because it can contain explicit environment variable values. ## Testing - Verify shell handlers and unified exec filter inherited variables according to the selected environment while preserving its explicit overrides. GitOrigin-RevId: 9f6a52aa4af60e5f17251a0e1b11e1926b779055 --- codex-rs/core/src/agents_md_tests.rs | 1 + codex-rs/core/src/environment_selection.rs | 5 + codex-rs/core/src/mcp_tool_call_tests.rs | 1 + codex-rs/core/src/session/environment.rs | 2 +- codex-rs/core/src/session/mod.rs | 8 +- codex-rs/core/src/session/session.rs | 15 +-- codex-rs/core/src/session/tests.rs | 24 +++- codex-rs/core/src/session/turn_context.rs | 9 +- codex-rs/core/src/tasks/user_shell.rs | 12 +- codex-rs/core/src/tools/handlers/shell.rs | 7 +- .../src/tools/handlers/shell/shell_command.rs | 2 +- .../core/src/tools/handlers/shell_tests.rs | 30 ++++- .../src/tools/runtimes/apply_patch_tests.rs | 1 + .../core/src/tools/runtimes/shell_tests.rs | 1 + .../core/src/tools/runtimes/unified_exec.rs | 1 + codex-rs/core/src/tools/spec_plan_tests.rs | 1 + .../core/src/unified_exec/process_manager.rs | 17 +-- codex-rs/core/tests/suite/guardian_review.rs | 1 + .../core/tests/suite/mcp_tool_exposure.rs | 1 + codex-rs/core/tests/suite/remote_env.rs | 7 ++ codex-rs/core/tests/suite/unified_exec.rs | 105 ++++++++++++++++++ codex-rs/protocol/src/environment.rs | 20 +++- 22 files changed, 219 insertions(+), 52 deletions(-) diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index a68079ab20..28b894c911 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -344,6 +344,7 @@ fn resolved_local_environments( permission_profile: PermissionProfileSnapshot::legacy( PermissionProfile::read_only(), ), + shell_environment_policy: Default::default(), selected_capability_roots: Vec::new(), }), }, diff --git a/codex-rs/core/src/environment_selection.rs b/codex-rs/core/src/environment_selection.rs index 0328402251..a67d0ba0d7 100644 --- a/codex-rs/core/src/environment_selection.rs +++ b/codex-rs/core/src/environment_selection.rs @@ -786,6 +786,7 @@ mod tests { EnvironmentConfig { allow_login_shell: true, permission_profile: PermissionProfileSnapshot::legacy(PermissionProfile::read_only()), + shell_environment_policy: Default::default(), selected_capability_roots: Vec::new(), } } @@ -957,6 +958,7 @@ url = "ws://127.0.0.1:8765" ActivePermissionProfile::read_only(), vec![cwd.join("profile-root")], ), + shell_environment_policy: Default::default(), selected_capability_roots: Vec::new(), }; let turn_environments = ThreadEnvironments::new( @@ -1158,6 +1160,7 @@ url = "ws://127.0.0.1:8765" ActivePermissionProfile::read_only(), vec![cwd.join("profile-root")], ), + shell_environment_policy: Default::default(), selected_capability_roots: Vec::new(), }; let cwd = PathUri::from_abs_path(&cwd); @@ -1491,6 +1494,7 @@ url = "ws://127.0.0.1:8765" ActivePermissionProfile::read_only(), vec![cwd.join("child-profile-root")], ), + shell_environment_policy: Default::default(), selected_capability_roots: Vec::new(), }; let environments = ThreadEnvironments::new( @@ -1534,6 +1538,7 @@ url = "ws://127.0.0.1:8765" let parent_owner_config = EnvironmentConfig { allow_login_shell: false, permission_profile: PermissionProfileSnapshot::legacy(PermissionProfile::read_only()), + shell_environment_policy: Default::default(), selected_capability_roots: vec![root("parent-root")], }; let mut owner_selection = selection.clone(); diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 2160515213..70dd201d78 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -1120,6 +1120,7 @@ async fn mcp_sandbox_cwd_uses_matching_server_environment_uri() -> anyhow::Resul .permissions .permission_profile_state() .snapshot(), + shell_environment_policy: Default::default(), selected_capability_roots: Vec::new(), }), }, diff --git a/codex-rs/core/src/session/environment.rs b/codex-rs/core/src/session/environment.rs index 63e8638e2c..8d4019ae95 100644 --- a/codex-rs/core/src/session/environment.rs +++ b/codex-rs/core/src/session/environment.rs @@ -109,7 +109,7 @@ impl Session { self.mark_mcp_runtime_dirty(); self.services.turn_environments.update_selections( &environments, - &state.session_configuration.turn_environment_config(), + &state.session_configuration.inferred_environment_config(), ); Ok(()) } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 03ce90bca9..340f471ef7 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -695,6 +695,8 @@ impl Session { approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, permission_profile_state: session_permission_profile_state_from_config(&config)?, + allow_login_shell: config.permissions.allow_login_shell, + shell_environment_policy: config.permissions.shell_environment_policy.clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), legacy_fallback_cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), @@ -1596,12 +1598,14 @@ impl Session { if mcp_inputs_changed { self.mark_mcp_runtime_dirty(); } - let environment_config = updated.turn_environment_config(); + let environment_config = updated.inferred_environment_config(); if let Some(environments) = &updates.environments { self.services .turn_environments .update_selections(&environments.environments, &environment_config); - } else if state.session_configuration.turn_environment_config() != environment_config { + } else if state.session_configuration.inferred_environment_config() + != environment_config + { self.services .turn_environments .update_thread_config(&environment_config); diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index cb792abf0d..9d5ff5de6a 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -16,6 +16,7 @@ use codex_protocol::SessionId; use codex_protocol::capabilities::SelectedCapabilityRoot; use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_protocol::config_types::ServiceTier; +use codex_protocol::config_types::ShellEnvironmentPolicy; use codex_protocol::mcp::ClientMcpExtensions; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSpecialPath; @@ -90,6 +91,8 @@ pub(crate) struct SessionConfiguration { /// active profile id, and profile-defined workspace roots in sync by using /// the methods below instead of mutating the fields independently. pub(super) permission_profile_state: PermissionProfileState, + pub(super) allow_login_shell: bool, + pub(super) shell_environment_policy: ShellEnvironmentPolicy, pub(super) windows_sandbox_level: WindowsSandboxLevel, /// Legacy thread cwd used when a turn does not select an environment. @@ -136,13 +139,11 @@ impl SessionConfiguration { &self.permission_profile_state } - pub(super) fn turn_environment_config(&self) -> EnvironmentConfig { + pub(super) fn inferred_environment_config(&self) -> EnvironmentConfig { EnvironmentConfig { - allow_login_shell: self - .original_config_do_not_use - .permissions - .allow_login_shell, + allow_login_shell: self.allow_login_shell, permission_profile: self.permission_profile_state.snapshot(), + shell_environment_policy: self.shell_environment_policy.clone(), selected_capability_roots: Vec::new(), } } @@ -1080,14 +1081,14 @@ impl Session { let turn_environments = Arc::new(ThreadEnvironments::new( environment_manager, default_shell.clone(), - session_configuration.turn_environment_config(), + session_configuration.inferred_environment_config(), shell_snapshot, inherited_environments.unwrap_or_default(), config.features.enabled(Feature::DeferredExecutor), )); turn_environments.update_selections( environment_selections, - &session_configuration.turn_environment_config(), + &session_configuration.inferred_environment_config(), ); let resolved_environments = turn_environments.snapshot().await; let agents_md_manager = Arc::new(AgentsMdManager::new(user_instructions)); diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 3005aae736..7146d2a1e6 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -4189,6 +4189,8 @@ async fn set_rate_limits_retains_previous_credits() { approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, permission_profile_state: config.permissions.permission_profile_state().clone(), + allow_login_shell: config.permissions.allow_login_shell, + shell_environment_policy: config.permissions.shell_environment_policy.clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), legacy_fallback_cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), @@ -4298,6 +4300,8 @@ async fn set_rate_limits_updates_plan_type_when_present() { approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, permission_profile_state: config.permissions.permission_profile_state().clone(), + allow_login_shell: config.permissions.allow_login_shell, + shell_environment_policy: config.permissions.shell_environment_policy.clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), legacy_fallback_cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), @@ -4852,6 +4856,8 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, permission_profile_state: config.permissions.permission_profile_state().clone(), + allow_login_shell: config.permissions.allow_login_shell, + shell_environment_policy: config.permissions.shell_environment_policy.clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), legacy_fallback_cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), @@ -4963,14 +4969,14 @@ async fn resolved_environments_for_configuration( let turn_environments = ThreadEnvironments::new( Arc::clone(&environment_manager), default_user_shell(), - session_configuration.turn_environment_config(), + session_configuration.inferred_environment_config(), ShellSnapshot::disabled(), TurnEnvironmentSnapshot::default(), /*non_blocking_snapshots*/ false, ); turn_environments.update_selections( environment_selections, - &session_configuration.turn_environment_config(), + &session_configuration.inferred_environment_config(), ); (environment_manager, turn_environments.snapshot().await) } @@ -5641,6 +5647,8 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() { approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, permission_profile_state: config.permissions.permission_profile_state().clone(), + allow_login_shell: config.permissions.allow_login_shell, + shell_environment_policy: config.permissions.shell_environment_policy.clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), legacy_fallback_cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), @@ -5787,6 +5795,8 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, permission_profile_state: config.permissions.permission_profile_state().clone(), + allow_login_shell: config.permissions.allow_login_shell, + shell_environment_policy: config.permissions.shell_environment_policy.clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), legacy_fallback_cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), @@ -5820,7 +5830,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { let turn_environments = Arc::new(ThreadEnvironments::new( environment_manager, default_user_shell(), - session_configuration.turn_environment_config(), + session_configuration.inferred_environment_config(), ShellSnapshot::disabled(), resolved_environments, /*non_blocking_snapshots*/ false, @@ -6068,6 +6078,8 @@ async fn make_session_with_config_and_rx( approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, permission_profile_state: config.permissions.permission_profile_state().clone(), + allow_login_shell: config.permissions.allow_login_shell, + shell_environment_policy: config.permissions.shell_environment_policy.clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), legacy_fallback_cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), @@ -6187,6 +6199,8 @@ async fn make_session_with_history_source_and_agent_control_and_rx( approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, permission_profile_state: config.permissions.permission_profile_state().clone(), + allow_login_shell: config.permissions.allow_login_shell, + shell_environment_policy: config.permissions.shell_environment_policy.clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), legacy_fallback_cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), @@ -7987,6 +8001,8 @@ where approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, permission_profile_state: config.permissions.permission_profile_state().clone(), + allow_login_shell: config.permissions.allow_login_shell, + shell_environment_policy: config.permissions.shell_environment_policy.clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), legacy_fallback_cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), @@ -8019,7 +8035,7 @@ where let turn_environments = Arc::new(ThreadEnvironments::new( environment_manager, default_user_shell(), - session_configuration.turn_environment_config(), + session_configuration.inferred_environment_config(), ShellSnapshot::disabled(), resolved_turn_environments.clone(), /*non_blocking_snapshots*/ false, diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 356cabbc39..e4592b5dc4 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -12,6 +12,7 @@ use codex_file_system::FileSystemSandboxContext; use codex_model_provider::SharedModelProvider; use codex_protocol::SessionId; use codex_protocol::ThreadId; +use codex_protocol::config_types::ShellEnvironmentPolicy; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::openai_models::MODEL_SPECIALTY_CYBER; use codex_protocol::openai_models::ModelInfo; @@ -66,6 +67,10 @@ impl TurnEnvironment { config } + pub(crate) fn shell_environment_policy(&self) -> &ShellEnvironmentPolicy { + &self.config().shell_environment_policy + } + #[cfg(test)] pub(crate) fn config_mut(&mut self) -> &mut EnvironmentConfig { let EnvironmentConfigState::Ready(config) = &mut self.selection.config else { @@ -712,12 +717,12 @@ impl Session { previous_permission_profile != next_permission_profile; let previous_config = notify_config_contributors .then(|| self.build_effective_session_config(&state.session_configuration)); - let environment_config = next.turn_environment_config(); + let environment_config = next.inferred_environment_config(); if let Some(environments) = &updates.environments { self.services .turn_environments .update_selections(&environments.environments, &environment_config); - } else if state.session_configuration.turn_environment_config() + } else if state.session_configuration.inferred_environment_config() != environment_config { self.services diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 0a88656ac8..e2d60cd6f4 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -158,10 +158,8 @@ pub(crate) async fn execute_user_shell_command( return; }; let shell_snapshot_location = turn_environment.shell_snapshot(&cwd); - let mut exec_env_map = create_env( - &turn_context.config.permissions.shell_environment_policy, - Some(session.thread_id), - ); + let shell_environment_policy = turn_environment.shell_environment_policy(); + let mut exec_env_map = create_env(shell_environment_policy, Some(session.thread_id)); inject_session_id_env(&mut exec_env_map, session.session_id()); inject_apply_patch_env(&mut exec_env_map, &turn_context.config.features); if exec_env_map.contains_key(PROXY_ACTIVE_ENV_KEY) { @@ -171,11 +169,7 @@ pub(crate) async fn execute_user_shell_command( &display_command, environment_shell, shell_snapshot_location.as_ref(), - &turn_context - .config - .permissions - .shell_environment_policy - .r#set, + &shell_environment_policy.r#set, &mut exec_env_map, ); diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index bde82e3dbb..ffd35d5a56 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -81,12 +81,7 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result crate::session::turn_context:: permission_profile: PermissionProfileSnapshot::legacy( PermissionProfile::read_only(), ), + shell_environment_policy: Default::default(), selected_capability_roots: Vec::new(), }), }, diff --git a/codex-rs/core/src/tools/runtimes/shell_tests.rs b/codex-rs/core/src/tools/runtimes/shell_tests.rs index 04301bf669..598370ddfe 100644 --- a/codex-rs/core/src/tools/runtimes/shell_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell_tests.rs @@ -27,6 +27,7 @@ async fn approval_key_uses_path_uri_and_includes_environment_id() { permission_profile: PermissionProfileSnapshot::legacy( PermissionProfile::read_only(), ), + shell_environment_policy: Default::default(), selected_capability_roots: Vec::new(), }), }, diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 1daebe95d2..0bfce9bf1d 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -542,6 +542,7 @@ mod tests { permission_profile: PermissionProfileSnapshot::legacy( PermissionProfile::read_only(), ), + shell_environment_policy: Default::default(), selected_capability_roots: Vec::new(), }), }, diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 87ab8df556..7c443e70ee 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -916,6 +916,7 @@ async fn zsh_fork_unified_exec_keeps_shell_parameter_when_remote_environment_ava .permissions .permission_profile_state() .snapshot(), + shell_environment_policy: Default::default(), selected_capability_roots: Vec::new(), }, ), diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 6b12ea2c52..5aa46e8eac 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -1217,10 +1217,8 @@ impl UnifiedExecProcessManager { context: &UnifiedExecContext, ) -> Result<(UnifiedExecAttempt, Option), UnifiedExecError> { let turn = &context.step_context.turn; - let local_policy_env = create_env( - &turn.config.permissions.shell_environment_policy, - /*thread_id*/ None, - ); + let shell_environment_policy = request.turn_environment.shell_environment_policy(); + let local_policy_env = create_env(shell_environment_policy, /*thread_id*/ None); let mut env = local_policy_env.clone(); env.insert( CODEX_THREAD_ID_ENV_VAR.to_string(), @@ -1232,17 +1230,10 @@ impl UnifiedExecProcessManager { inject_permission_profile_env(&mut env, active_permission_profile.as_ref()); let mut env = apply_unified_exec_env(env); strip_output_env(&mut env); - let mut explicit_env_overrides = turn - .config - .permissions - .shell_environment_policy - .r#set - .clone(); + let mut explicit_env_overrides = shell_environment_policy.r#set.clone(); strip_output_env(&mut explicit_env_overrides); let exec_server_env_config = ExecServerEnvConfig { - policy: exec_env_policy_from_shell_policy( - &turn.config.permissions.shell_environment_policy, - ), + policy: exec_env_policy_from_shell_policy(shell_environment_policy), local_policy_env, }; let mut orchestrator = ToolOrchestrator::new(); diff --git a/codex-rs/core/tests/suite/guardian_review.rs b/codex-rs/core/tests/suite/guardian_review.rs index d607619cef..e40be262f8 100644 --- a/codex-rs/core/tests/suite/guardian_review.rs +++ b/codex-rs/core/tests/suite/guardian_review.rs @@ -597,6 +597,7 @@ async fn guardian_session_is_reused_for_consecutive_tool_reviews_without_prewarm permission_profile: PermissionProfileSnapshot::legacy( test.config.permissions.permission_profile().clone(), ), + shell_environment_policy: Default::default(), selected_capability_roots: Vec::new(), }; parent_environments diff --git a/codex-rs/core/tests/suite/mcp_tool_exposure.rs b/codex-rs/core/tests/suite/mcp_tool_exposure.rs index 5a666df086..b62fd5f3b8 100644 --- a/codex-rs/core/tests/suite/mcp_tool_exposure.rs +++ b/codex-rs/core/tests/suite/mcp_tool_exposure.rs @@ -482,6 +482,7 @@ async fn root_reconciliation_reuses_pending_apps_startup() -> Result<()> { permission_profile: PermissionProfileSnapshot::legacy( test.config.permissions.permission_profile().clone(), ), + shell_environment_policy: Default::default(), selected_capability_roots: vec![SelectedCapabilityRoot { id: "calendar-root".to_string(), location: CapabilityRootLocation::Environment { diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index 7c0485facb..88a39478bb 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -550,6 +550,7 @@ async fn environment_permissions_follow_configuration_ownership() -> Result<()> permission_profile: PermissionProfileSnapshot::legacy( PermissionProfile::read_only(), ), + shell_environment_policy: Default::default(), selected_capability_roots: Vec::new(), }), ..selection @@ -1171,6 +1172,7 @@ async fn shared_executor_keeps_ready_capability_roots_scoped_to_each_attachment( EnvironmentConfigState::Ready(EnvironmentConfig { allow_login_shell: false, permission_profile: permission_profile.clone(), + shell_environment_policy: Default::default(), selected_capability_roots: vec![root("duplicate"), root("duplicate")], }), ] { @@ -1205,6 +1207,7 @@ async fn shared_executor_keeps_ready_capability_roots_scoped_to_each_attachment( config: EnvironmentConfigState::Ready(EnvironmentConfig { allow_login_shell: true, permission_profile: permission_profile.clone(), + shell_environment_policy: Default::default(), selected_capability_roots: vec![root("startup-root"), root("second-root")], }), ..selection.clone() @@ -1223,6 +1226,7 @@ async fn shared_executor_keeps_ready_capability_roots_scoped_to_each_attachment( config: EnvironmentConfigState::Ready(EnvironmentConfig { allow_login_shell: false, permission_profile: permission_profile.clone(), + shell_environment_policy: Default::default(), selected_capability_roots: vec![root("first-root")], }), ..selection.clone() @@ -1280,6 +1284,7 @@ async fn shared_executor_keeps_ready_capability_roots_scoped_to_each_attachment( config: EnvironmentConfigState::Ready(EnvironmentConfig { allow_login_shell: false, permission_profile: permission_profile.clone(), + shell_environment_policy: Default::default(), selected_capability_roots: vec![root("first-updated-root")], }), ..selection.clone() @@ -1375,6 +1380,7 @@ async fn pending_attachment_installs_configuration_before_waiting_turn_resumes() let owner_config = |id: &str, allow_login_shell: bool| EnvironmentConfig { allow_login_shell, permission_profile: PermissionProfileSnapshot::legacy(PermissionProfile::read_only()), + shell_environment_policy: Default::default(), selected_capability_roots: vec![root(id)], }; let start_pending_thread = || { @@ -1690,6 +1696,7 @@ async fn ready_before_selection_exposes_remote_tools_and_capability_context_afte permission_profile: PermissionProfileSnapshot::legacy( test.config.permissions.permission_profile().clone(), ), + shell_environment_policy: Default::default(), selected_capability_roots: vec![ready_root], }), }]), diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index f259dd0207..7395c6fee8 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -1,3 +1,4 @@ +use codex_core::EnvironmentConfig; use codex_core::TurnInputRequest; use core_test_support::test_codex::local_selections; use std::collections::HashMap; @@ -10,9 +11,13 @@ use anyhow::Result; use codex_exec_server::CreateDirectoryOptions; use codex_features::Feature; use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::EnvironmentVariablePattern; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; +use codex_protocol::config_types::ShellEnvironmentPolicy; +use codex_protocol::config_types::ShellEnvironmentPolicyInherit; use codex_protocol::models::PermissionProfile; +use codex_protocol::models::PermissionProfileSnapshot; use codex_protocol::models::ResponseItem; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; @@ -296,6 +301,106 @@ async fn exec_command_hides_and_rejects_login_when_disabled() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_command_uses_installed_environment_shell_policy_with_explicit_overrides() -> Result<()> +{ + skip_if_wine_exec!( + Ok(()), + "basic PowerShell execution through Wine exec is not passing yet" + ); + skip_if_no_network!(Ok(())); + + let builder = test_codex().with_model("gpt-5.4").with_config(|config| { + config.use_experimental_unified_exec_tool = true; + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); + config.permissions.shell_environment_policy = ShellEnvironmentPolicy { + inherit: ShellEnvironmentPolicyInherit::None, + include_only: vec![EnvironmentVariablePattern::new_case_insensitive("DROP")], + r#set: HashMap::from([ + ("KEEP".to_string(), "preserved".to_string()), + ("DROP".to_string(), "filtered".to_string()), + ]), + ..Default::default() + }; + }); + let harness = TestCodexHarness::with_auto_env_builder(builder).await?; + let selection = harness + .test() + .codex + .environment_selections() + .await + .into_iter() + .next() + .context("thread should select its executor environment")?; + harness + .test() + .codex + .environment_ready( + &selection, + EnvironmentConfig { + allow_login_shell: true, + permission_profile: PermissionProfileSnapshot::legacy(PermissionProfile::Disabled), + shell_environment_policy: ShellEnvironmentPolicy { + inherit: ShellEnvironmentPolicyInherit::None, + include_only: vec![EnvironmentVariablePattern::new_case_insensitive("KEEP")], + r#set: harness + .test() + .config + .permissions + .shell_environment_policy + .r#set + .clone(), + ..Default::default() + }, + selected_capability_roots: Vec::new(), + }, + ) + .await?; + + let call_id = "exec-command-environment-shell-policy"; + let command = match core_test_support::test_target_os() { + core_test_support::TestTargetOs::Linux | core_test_support::TestTargetOs::MacOs => { + r#"printf '%s:%s:%s' "$KEEP" "${DROP:-missing}" "${OWNER_ONLY:-missing}""# + } + core_test_support::TestTargetOs::Windows => { + r#"if (Test-Path Env:DROP) { $drop = $env:DROP } else { $drop = 'missing' }; if (Test-Path Env:OWNER_ONLY) { $owner = $env:OWNER_ONLY } else { $owner = 'missing' }; Write-Output "${env:KEEP}:${drop}:${owner}""# + } + }; + let arguments = json!({ + "cmd": command, + "login": false, + "yield_time_ms": 5_000, + }); + mount_sse_sequence( + harness.server(), + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&arguments)?), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + harness + .submit_with_permission_profile( + "inspect the shell environment", + PermissionProfile::Disabled, + ) + .await?; + let output = parse_unified_exec_output(&harness.function_call_stdout(call_id).await)?; + assert_eq!(output.output.trim(), "preserved:missing:missing"); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/protocol/src/environment.rs b/codex-rs/protocol/src/environment.rs index 8c28f86816..5624034c48 100644 --- a/codex-rs/protocol/src/environment.rs +++ b/codex-rs/protocol/src/environment.rs @@ -1,8 +1,10 @@ use crate::capabilities::SelectedCapabilityRoot; +use crate::config_types::ShellEnvironmentPolicy; use crate::models::PermissionProfileSnapshot; /// Configuration supplied for a thread's selected environment. -#[derive(Clone, Debug, PartialEq, Eq)] +#[allow(clippy::large_enum_variant)] +#[derive(Clone, Debug, PartialEq)] pub enum EnvironmentConfigState { /// Preserve the existing thread-derived environment configuration. FromThread, @@ -15,12 +17,26 @@ pub enum EnvironmentConfigState { } /// Resolved configuration for a thread/environment attachment. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, PartialEq)] pub struct EnvironmentConfig { /// Whether shell tools may start login shells in this environment. pub allow_login_shell: bool, /// Resolved permissions for this thread's environment attachment. pub permission_profile: PermissionProfileSnapshot, + /// Controls which environment variables shell commands may inherit. + pub shell_environment_policy: ShellEnvironmentPolicy, /// Capability roots selected for this thread's environment attachment. pub selected_capability_roots: Vec, } + +impl std::fmt::Debug for EnvironmentConfig { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("EnvironmentConfig") + .field("allow_login_shell", &self.allow_login_shell) + .field("permission_profile", &self.permission_profile) + .field("shell_environment_policy", &"") + .field("selected_capability_roots", &self.selected_capability_roots) + .finish() + } +}