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
This commit is contained in:
sayan-oai
2026-08-16 20:35:05 +00:00
committed by copyberry
parent f85e81d30b
commit 6c108912ee
22 changed files with 219 additions and 52 deletions
+1
View File
@@ -344,6 +344,7 @@ fn resolved_local_environments<const N: usize>(
permission_profile: PermissionProfileSnapshot::legacy(
PermissionProfile::read_only(),
),
shell_environment_policy: Default::default(),
selected_capability_roots: Vec::new(),
}),
},
@@ -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();
+1
View File
@@ -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(),
}),
},
+1 -1
View File
@@ -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(())
}
+6 -2
View File
@@ -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);
+8 -7
View File
@@ -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));
+20 -4
View File
@@ -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,
+7 -2
View File
@@ -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
+3 -9
View File
@@ -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,
);
+1 -6
View File
@@ -81,12 +81,7 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result<FunctionToolOutput, Func
let fs = turn_environment.environment.get_filesystem();
let mut explicit_env_overrides = turn
.config
.permissions
.shell_environment_policy
.r#set
.clone();
let mut explicit_env_overrides = turn_environment.shell_environment_policy().r#set.clone();
let mut env = exec_params.env.clone();
strip_output_env(&mut env);
strip_output_env(&mut explicit_env_overrides);
@@ -108,7 +108,7 @@ impl ShellCommandHandler {
let command = Self::base_command(shell, &params.command, use_login_shell);
let mut env = create_env(
&turn_context.config.permissions.shell_environment_policy,
turn_environment.shell_environment_policy(),
Some(session.thread_id),
);
inject_session_id_env(&mut env, session.session_id());
@@ -1,6 +1,10 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use codex_protocol::config_types::EnvironmentVariablePattern;
use codex_protocol::config_types::ShellEnvironmentPolicy;
use codex_protocol::config_types::ShellEnvironmentPolicyInherit;
use codex_protocol::models::ActivePermissionProfile;
use codex_protocol::models::ShellCommandToolCallParams;
use pretty_assertions::assert_eq;
@@ -82,7 +86,14 @@ fn assert_safe(shell: &Shell, command: &str) {
async fn shell_command_handler_to_exec_params_uses_selected_environment() {
let (session, mut turn_context) = make_session_and_context().await;
let permission_profile = turn_context.config.permissions.permission_profile().clone();
Arc::make_mut(&mut turn_context.config)
let config = Arc::make_mut(&mut turn_context.config);
config.permissions.shell_environment_policy.r#set = HashMap::from([
("KEEP".to_string(), "from-thread".to_string()),
("DROP".to_string(), "from-thread".to_string()),
]);
config.permissions.shell_environment_policy.include_only =
vec![EnvironmentVariablePattern::new_case_insensitive("DROP")];
config
.permissions
.set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active(
permission_profile.clone(),
@@ -105,6 +116,17 @@ async fn shell_command_handler_to_exec_params_uses_selected_environment() {
let selected_cwd = turn_context.config.cwd.join("selected-environment");
let expected_cwd = selected_cwd.join("subdir");
let active_permission_profile = ActivePermissionProfile::new("selected-profile");
let selected_shell_environment_policy = ShellEnvironmentPolicy {
inherit: ShellEnvironmentPolicyInherit::None,
include_only: vec![EnvironmentVariablePattern::new_case_insensitive("KEEP")],
r#set: turn_context
.config
.permissions
.shell_environment_policy
.r#set
.clone(),
..Default::default()
};
let selected_environment = TurnEnvironment::new(
TurnEnvironmentSelection {
environment_id: "selected-environment".to_string(),
@@ -116,6 +138,7 @@ async fn shell_command_handler_to_exec_params_uses_selected_environment() {
permission_profile,
active_permission_profile.clone(),
),
shell_environment_policy: selected_shell_environment_policy.clone(),
selected_capability_roots: Vec::new(),
}),
},
@@ -129,10 +152,7 @@ async fn shell_command_handler_to_exec_params_uses_selected_environment() {
),
Some(selected_shell),
);
let mut expected_env = create_env(
&turn_context.config.permissions.shell_environment_policy,
Some(session.thread_id),
);
let mut expected_env = create_env(&selected_shell_environment_policy, Some(session.thread_id));
inject_session_id_env(&mut expected_env, session.session_id());
inject_permission_profile_env(&mut expected_env, Some(&active_permission_profile));
@@ -30,6 +30,7 @@ fn test_turn_environment(environment_id: &str) -> crate::session::turn_context::
permission_profile: PermissionProfileSnapshot::legacy(
PermissionProfile::read_only(),
),
shell_environment_policy: Default::default(),
selected_capability_roots: Vec::new(),
}),
},
@@ -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(),
}),
},
@@ -542,6 +542,7 @@ mod tests {
permission_profile: PermissionProfileSnapshot::legacy(
PermissionProfile::read_only(),
),
shell_environment_policy: Default::default(),
selected_capability_roots: Vec::new(),
}),
},
@@ -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(),
},
),
@@ -1217,10 +1217,8 @@ impl UnifiedExecProcessManager {
context: &UnifiedExecContext,
) -> Result<(UnifiedExecAttempt, Option<DeferredNetworkApproval>), 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();
@@ -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
@@ -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 {
+7
View File
@@ -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],
}),
}]),
+105
View File
@@ -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(()));
+18 -2
View File
@@ -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<SelectedCapabilityRoot>,
}
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", &"<redacted>")
.field("selected_capability_roots", &self.selected_capability_roots)
.finish()
}
}