From 17ee2bd25ddeb917a4d4bd6c5af30717bf78bcad Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 25 Jun 2026 11:20:46 -0700 Subject: [PATCH] core: expose permission profile to shell tools --- codex-rs/core/src/exec_env.rs | 26 ++ codex-rs/core/src/exec_env_tests.rs | 53 +++ .../src/tools/handlers/shell/shell_command.rs | 13 +- .../core/src/tools/handlers/shell_tests.rs | 24 +- codex-rs/core/src/tools/runtimes/mod.rs | 18 +- codex-rs/core/src/tools/runtimes/mod_tests.rs | 72 ++++ .../core/src/unified_exec/process_manager.rs | 25 +- .../src/unified_exec/process_manager_tests.rs | 37 ++ codex-rs/core/tests/common/zsh_fork.rs | 35 +- codex-rs/core/tests/suite/approvals.rs | 370 ++++++++++++++++++ 10 files changed, 642 insertions(+), 31 deletions(-) diff --git a/codex-rs/core/src/exec_env.rs b/codex-rs/core/src/exec_env.rs index 938667b12ed4..f33061698b2d 100644 --- a/codex-rs/core/src/exec_env.rs +++ b/codex-rs/core/src/exec_env.rs @@ -2,11 +2,16 @@ use codex_protocol::ThreadId; #[cfg(test)] use codex_protocol::config_types::EnvironmentVariablePattern; use codex_protocol::config_types::ShellEnvironmentPolicy; +use codex_protocol::models::ActivePermissionProfile; use codex_protocol::shell_environment; use std::collections::HashMap; pub use codex_protocol::shell_environment::CODEX_THREAD_ID_ENV_VAR; +/// Informational name of the active permission profile. Child processes can +/// overwrite this value, so it must not be treated as proof of enforcement. +pub const CODEX_PERMISSION_PROFILE_ENV_VAR: &str = "CODEX_PERMISSION_PROFILE"; + /// Construct an environment map based on the rules in the specified policy. The /// resulting map can be passed directly to `Command::envs()` after calling /// `env_clear()` to ensure no unintended variables are leaked to the spawned @@ -25,6 +30,27 @@ pub fn create_env( shell_environment::create_env(policy, thread_id.as_deref()) } +/// Injects the selected named permission profile into a shell tool's environment. +/// +/// This is applied after the shell environment policy so the runtime-selected +/// profile wins over inherited or configured values. +pub(crate) fn inject_permission_profile_env( + env: &mut HashMap, + active_permission_profile: Option<&ActivePermissionProfile>, +) { + if cfg!(windows) { + env.retain(|key, _| !key.eq_ignore_ascii_case(CODEX_PERMISSION_PROFILE_ENV_VAR)); + } else { + env.remove(CODEX_PERMISSION_PROFILE_ENV_VAR); + } + if let Some(active_permission_profile) = active_permission_profile { + env.insert( + CODEX_PERMISSION_PROFILE_ENV_VAR.to_string(), + active_permission_profile.id.clone(), + ); + } +} + #[cfg(all(test, target_os = "windows"))] fn create_env_from_vars( vars: I, diff --git a/codex-rs/core/src/exec_env_tests.rs b/codex-rs/core/src/exec_env_tests.rs index 725edd8cc505..73c0944c1418 100644 --- a/codex-rs/core/src/exec_env_tests.rs +++ b/codex-rs/core/src/exec_env_tests.rs @@ -10,6 +10,59 @@ fn make_vars(pairs: &[(&str, &str)]) -> Vec<(String, String)> { .collect() } +#[test] +fn inject_permission_profile_env_overrides_policy_value() { + let mut env = HashMap::from([( + CODEX_PERMISSION_PROFILE_ENV_VAR.to_string(), + "stale-profile".to_string(), + )]); + + inject_permission_profile_env( + &mut env, + Some(&ActivePermissionProfile::new("current-profile")), + ); + + assert_eq!( + env.get(CODEX_PERMISSION_PROFILE_ENV_VAR) + .map(String::as_str), + Some("current-profile") + ); +} + +#[test] +fn inject_permission_profile_env_removes_stale_value_without_active_profile() { + let mut env = HashMap::from([( + CODEX_PERMISSION_PROFILE_ENV_VAR.to_string(), + "stale-profile".to_string(), + )]); + + inject_permission_profile_env(&mut env, /*active_permission_profile*/ None); + + assert_eq!(env.get(CODEX_PERMISSION_PROFILE_ENV_VAR), None); +} + +#[cfg(target_os = "windows")] +#[test] +fn inject_permission_profile_env_replaces_differently_cased_windows_key() { + let mut env = HashMap::from([( + "codex_permission_profile".to_string(), + "stale-profile".to_string(), + )]); + + inject_permission_profile_env( + &mut env, + Some(&ActivePermissionProfile::new("current-profile")), + ); + + assert_eq!( + env, + HashMap::from([( + CODEX_PERMISSION_PROFILE_ENV_VAR.to_string(), + "current-profile".to_string(), + )]) + ); +} + #[test] fn test_core_inherit_defaults_keep_sensitive_vars() { let vars = make_vars(&[ diff --git a/codex-rs/core/src/tools/handlers/shell/shell_command.rs b/codex-rs/core/src/tools/handlers/shell/shell_command.rs index 1faa89b31fd0..62bcee8bdffa 100644 --- a/codex-rs/core/src/tools/handlers/shell/shell_command.rs +++ b/codex-rs/core/src/tools/handlers/shell/shell_command.rs @@ -6,6 +6,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; use crate::exec::ExecCapturePolicy; use crate::exec::ExecParams; use crate::exec_env::create_env; +use crate::exec_env::inject_permission_profile_env; use crate::function_tool::FunctionCallError; use crate::maybe_emit_implicit_skill_invocation; use crate::session::turn_context::TurnContext; @@ -99,15 +100,19 @@ impl ShellCommandHandler { let use_login_shell = Self::resolve_use_login_shell(params.login, allow_login_shell)?; let command = Self::base_command(shell, ¶ms.command, use_login_shell); + let mut env = create_env( + &turn_context.config.permissions.shell_environment_policy, + Some(session.thread_id), + ); + let active_permission_profile = turn_context.config.permissions.active_permission_profile(); + inject_permission_profile_env(&mut env, active_permission_profile.as_ref()); + Ok(ExecParams { command, cwd, expiration: params.timeout_ms.into(), capture_policy: ExecCapturePolicy::ShellTool, - env: create_env( - &turn_context.config.permissions.shell_environment_policy, - Some(session.thread_id), - ), + env, network: turn_context.network.clone(), network_environment_id: Some(turn_environment.environment_id.clone()), sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), diff --git a/codex-rs/core/src/tools/handlers/shell_tests.rs b/codex-rs/core/src/tools/handlers/shell_tests.rs index ded77330221a..ebfbf9127c36 100644 --- a/codex-rs/core/src/tools/handlers/shell_tests.rs +++ b/codex-rs/core/src/tools/handlers/shell_tests.rs @@ -1,10 +1,14 @@ use std::path::PathBuf; use std::sync::Arc; +use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::ShellCommandToolCallParams; use pretty_assertions::assert_eq; +use crate::config::PermissionProfileSnapshot; +use crate::exec_env::CODEX_PERMISSION_PROFILE_ENV_VAR; use crate::exec_env::create_env; +use crate::exec_env::inject_permission_profile_env; use crate::sandboxing::SandboxPermissions; use crate::session::step_context::StepContext; use crate::session::tests::make_session_and_context; @@ -71,7 +75,15 @@ fn assert_safe(shell: &Shell, command: &str) { #[tokio::test] async fn shell_command_handler_to_exec_params_uses_selected_environment() { - let (session, turn_context) = make_session_and_context().await; + 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) + .permissions + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( + permission_profile, + ActivePermissionProfile::new("test-profile"), + )) + .expect("set active permission profile"); let command = "echo hello".to_string(); let workdir = Some("subdir".to_string()); @@ -99,10 +111,12 @@ async fn shell_command_handler_to_exec_params_uses_selected_environment() { PathUri::from_abs_path(&selected_cwd), Some(selected_shell), ); - let expected_env = create_env( + let mut expected_env = create_env( &turn_context.config.permissions.shell_environment_policy, Some(session.thread_id), ); + let active_permission_profile = turn_context.config.permissions.active_permission_profile(); + inject_permission_profile_env(&mut expected_env, active_permission_profile.as_ref()); let params = ShellCommandToolCallParams { command, @@ -129,6 +143,12 @@ async fn shell_command_handler_to_exec_params_uses_selected_environment() { assert_eq!(exec_params.command, expected_command); assert_eq!(exec_params.cwd, expected_cwd); assert_eq!(exec_params.env, expected_env); + assert_eq!( + exec_params.env.get(CODEX_PERMISSION_PROFILE_ENV_VAR), + active_permission_profile + .as_ref() + .map(|profile| &profile.id) + ); assert_eq!(exec_params.network, turn_context.network); assert_eq!( exec_params.network_environment_id.as_deref(), diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs index db55cc2bbc20..1a83ef56579b 100644 --- a/codex-rs/core/src/tools/runtimes/mod.rs +++ b/codex-rs/core/src/tools/runtimes/mod.rs @@ -4,6 +4,7 @@ Module: runtimes Concrete ToolRuntime implementations for specific tools. Each runtime stays small and focused and reuses the orchestrator for approvals + sandbox + retry. */ +use crate::exec_env::CODEX_PERMISSION_PROFILE_ENV_VAR; use crate::exec_env::CODEX_THREAD_ID_ENV_VAR; use crate::sandboxing::SandboxPermissions; use crate::shell::Shell; @@ -287,10 +288,14 @@ pub(crate) fn maybe_wrap_shell_lc_with_snapshot( .map(|arg| format!(" '{}'", shell_single_quote(arg))) .collect::(); let mut override_env = explicit_env_overrides.clone(); - if let Some(thread_id) = env.get(CODEX_THREAD_ID_ENV_VAR) { - override_env.insert(CODEX_THREAD_ID_ENV_VAR.to_string(), thread_id.clone()); + for key in [CODEX_THREAD_ID_ENV_VAR, CODEX_PERMISSION_PROFILE_ENV_VAR] { + if let Some(value) = env.get(key) { + override_env.insert(key.to_string(), value.clone()); + } } - let (override_captures, override_exports) = build_override_exports(&override_env); + // Do not let a snapshot resurrect a stale profile when no named profile is active. + let (override_captures, override_exports) = + build_override_exports(&override_env, &[CODEX_PERMISSION_PROFILE_ENV_VAR]); let (proxy_captures, proxy_exports) = build_proxy_env_exports(); let runtime_path_prepend_exports = runtime_path_prepends.shell_exports_after_snapshot(explicit_env_overrides); @@ -313,13 +318,18 @@ pub(crate) fn maybe_wrap_shell_lc_with_snapshot( vec![shell_path.to_string(), "-c".to_string(), rewritten_script] } -fn build_override_exports(explicit_env_overrides: &HashMap) -> (String, String) { +fn build_override_exports( + explicit_env_overrides: &HashMap, + restore_even_when_absent: &[&str], +) -> (String, String) { let mut keys = explicit_env_overrides .keys() .map(String::as_str) + .chain(restore_even_when_absent.iter().copied()) .filter(|key| is_valid_shell_variable_name(key)) .collect::>(); keys.sort_unstable(); + keys.dedup(); build_override_exports_for_keys("__CODEX_SNAPSHOT_OVERRIDE", &keys) } diff --git a/codex-rs/core/src/tools/runtimes/mod_tests.rs b/codex-rs/core/src/tools/runtimes/mod_tests.rs index 9b485dbe58d3..5c2614c9c07a 100644 --- a/codex-rs/core/src/tools/runtimes/mod_tests.rs +++ b/codex-rs/core/src/tools/runtimes/mod_tests.rs @@ -534,6 +534,78 @@ fn maybe_wrap_shell_lc_with_snapshot_restores_codex_thread_id_from_env() { assert_eq!(String::from_utf8_lossy(&output.stdout), "nested-thread"); } +#[test] +fn maybe_wrap_shell_lc_with_snapshot_restores_permission_profile_from_env() { + let dir = tempdir().expect("create temp dir"); + let snapshot_path = dir.path().join("snapshot.sh"); + std::fs::write( + &snapshot_path, + "# Snapshot file\nexport CODEX_PERMISSION_PROFILE='parent-profile'\n", + ) + .expect("write snapshot"); + let (session_shell, shell_snapshot) = + shell_with_snapshot(ShellType::Bash, "/bin/bash", snapshot_path.abs()); + let command = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + "printenv CODEX_PERMISSION_PROFILE".to_string(), + ]; + let env = HashMap::from([( + CODEX_PERMISSION_PROFILE_ENV_VAR.to_string(), + "current-profile".to_string(), + )]); + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + Some(&shell_snapshot), + &HashMap::new(), + &env, + &RuntimePathPrepends::default(), + ); + let output = Command::new(&rewritten[0]) + .args(&rewritten[1..]) + .env(CODEX_PERMISSION_PROFILE_ENV_VAR, "current-profile") + .output() + .expect("run rewritten command"); + + assert!(output.status.success(), "command failed: {output:?}"); + assert_eq!(String::from_utf8_lossy(&output.stdout), "current-profile\n"); +} + +#[test] +fn maybe_wrap_shell_lc_with_snapshot_unsets_absent_permission_profile() { + let dir = tempdir().expect("create temp dir"); + let snapshot_path = dir.path().join("snapshot.sh"); + std::fs::write( + &snapshot_path, + "# Snapshot file\nexport CODEX_PERMISSION_PROFILE='stale-profile'\n", + ) + .expect("write snapshot"); + let (session_shell, shell_snapshot) = + shell_with_snapshot(ShellType::Bash, "/bin/bash", snapshot_path.abs()); + let command = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + "printenv CODEX_PERMISSION_PROFILE".to_string(), + ]; + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + Some(&shell_snapshot), + &HashMap::new(), + &HashMap::new(), + &RuntimePathPrepends::default(), + ); + let output = Command::new(&rewritten[0]) + .args(&rewritten[1..]) + .env_remove(CODEX_PERMISSION_PROFILE_ENV_VAR) + .output() + .expect("run rewritten command"); + + assert_eq!(output.status.code(), Some(1)); + assert_eq!(output.stdout, b""); +} + #[test] fn maybe_wrap_shell_lc_with_snapshot_restores_proxy_env_from_process_env() { let dir = tempdir().expect("create temp dir"); diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 5e8944576567..ef22918cd299 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -13,8 +13,10 @@ use tokio_util::sync::CancellationToken; use uuid::Uuid; use crate::codex_thread::BackgroundTerminalInfo; +use crate::exec_env::CODEX_PERMISSION_PROFILE_ENV_VAR; use crate::exec_env::CODEX_THREAD_ID_ENV_VAR; use crate::exec_env::create_env; +use crate::exec_env::inject_permission_profile_env; use crate::exec_policy::ExecApprovalRequest; use crate::sandboxing::ExecOptions; use crate::sandboxing::ExecRequest; @@ -109,15 +111,19 @@ fn apply_unified_exec_env(mut env: HashMap) -> HashMap codex_exec_server::ExecEnvPolicy { + let mut exclude = policy + .exclude + .iter() + .map(std::string::ToString::to_string) + .collect::>(); + exclude.push(CODEX_PERMISSION_PROFILE_ENV_VAR.to_string()); + let mut r#set = policy.r#set.clone(); + r#set.retain(|key, _| !key.eq_ignore_ascii_case(CODEX_PERMISSION_PROFILE_ENV_VAR)); codex_exec_server::ExecEnvPolicy { inherit: policy.inherit.clone(), ignore_default_excludes: policy.ignore_default_excludes, - exclude: policy - .exclude - .iter() - .map(std::string::ToString::to_string) - .collect(), - r#set: policy.r#set.clone(), + exclude, + r#set, include_only: policy .include_only .iter() @@ -132,7 +138,10 @@ fn env_overlay_for_exec_server( ) -> HashMap { request_env .iter() - .filter(|(key, value)| local_policy_env.get(*key) != Some(*value)) + .filter(|(key, value)| { + key.as_str() == CODEX_PERMISSION_PROFILE_ENV_VAR + || local_policy_env.get(*key) != Some(*value) + }) .map(|(key, value)| (key.clone(), value.clone())) .collect() } @@ -1110,6 +1119,8 @@ impl UnifiedExecProcessManager { CODEX_THREAD_ID_ENV_VAR.to_string(), context.session.thread_id.to_string(), ); + let active_permission_profile = context.turn.config.permissions.active_permission_profile(); + inject_permission_profile_env(&mut env, active_permission_profile.as_ref()); let env = apply_unified_exec_env(env); let exec_server_env_config = ExecServerEnvConfig { policy: exec_env_policy_from_shell_policy( diff --git a/codex-rs/core/src/unified_exec/process_manager_tests.rs b/codex-rs/core/src/unified_exec/process_manager_tests.rs index 93ffd0cedb8a..096cbe0a1359 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -42,12 +42,20 @@ fn env_overlay_for_exec_server_keeps_runtime_changes_only() { ("HOME".to_string(), "/client-home".to_string()), ("PATH".to_string(), "/client-path".to_string()), ("SHELL_SET".to_string(), "policy".to_string()), + ( + CODEX_PERMISSION_PROFILE_ENV_VAR.to_string(), + "current-profile".to_string(), + ), ]); let request_env = HashMap::from([ ("HOME".to_string(), "/client-home".to_string()), ("PATH".to_string(), "/sandbox-path".to_string()), ("SHELL_SET".to_string(), "policy".to_string()), ("CODEX_THREAD_ID".to_string(), "thread-1".to_string()), + ( + CODEX_PERMISSION_PROFILE_ENV_VAR.to_string(), + "current-profile".to_string(), + ), ( "CODEX_SANDBOX_NETWORK_DISABLED".to_string(), "1".to_string(), @@ -59,6 +67,10 @@ fn env_overlay_for_exec_server_keeps_runtime_changes_only() { HashMap::from([ ("PATH".to_string(), "/sandbox-path".to_string()), ("CODEX_THREAD_ID".to_string(), "thread-1".to_string()), + ( + CODEX_PERMISSION_PROFILE_ENV_VAR.to_string(), + "current-profile".to_string(), + ), ( "CODEX_SANDBOX_NETWORK_DISABLED".to_string(), "1".to_string() @@ -67,6 +79,31 @@ fn env_overlay_for_exec_server_keeps_runtime_changes_only() { ); } +#[test] +fn exec_env_policy_excludes_runtime_permission_profile() { + let policy = ShellEnvironmentPolicy { + r#set: HashMap::from([ + ( + "codex_permission_profile".to_string(), + "stale-profile".to_string(), + ), + ("KEEP".to_string(), "value".to_string()), + ]), + ..Default::default() + }; + + assert_eq!( + exec_env_policy_from_shell_policy(&policy), + codex_exec_server::ExecEnvPolicy { + inherit: policy.inherit, + ignore_default_excludes: policy.ignore_default_excludes, + exclude: vec![CODEX_PERMISSION_PROFILE_ENV_VAR.to_string()], + r#set: HashMap::from([("KEEP".to_string(), "value".to_string())]), + include_only: Vec::new(), + } + ); +} + #[test] fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { let cwd: codex_utils_absolute_path::AbsolutePathBuf = std::env::current_dir() diff --git a/codex-rs/core/tests/common/zsh_fork.rs b/codex-rs/core/tests/common/zsh_fork.rs index 5abfc753b98e..8736da72c3cb 100644 --- a/codex-rs/core/tests/common/zsh_fork.rs +++ b/codex-rs/core/tests/common/zsh_fork.rs @@ -10,6 +10,7 @@ use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use crate::test_codex::TestCodex; +use crate::test_codex::TestCodexBuilder; use crate::test_codex::test_codex; #[derive(Clone)] @@ -19,12 +20,7 @@ pub struct ZshForkRuntime { } impl ZshForkRuntime { - fn apply_to_config( - &self, - config: &mut Config, - approval_policy: AskForApproval, - permission_profile: PermissionProfile, - ) { + fn apply_to_config(&self, config: &mut Config, approval_policy: AskForApproval) { config .features .enable(Feature::ShellTool) @@ -37,10 +33,6 @@ impl ZshForkRuntime { config.main_execve_wrapper_exe = Some(self.main_execve_wrapper_exe.clone()); config.permissions.allow_login_shell = false; config.permissions.approval_policy = Constrained::allow_any(approval_policy); - config - .permissions - .set_permission_profile(permission_profile) - .expect("set permission profile"); } } @@ -86,10 +78,13 @@ pub async fn build_zsh_fork_test( where F: FnOnce(&Path) + Send + 'static, { - let mut builder = test_codex() + let mut builder = zsh_fork_test_builder(runtime, approval_policy) .with_pre_build_hook(pre_build_hook) .with_config(move |config| { - runtime.apply_to_config(config, approval_policy, permission_profile); + config + .permissions + .set_permission_profile(permission_profile) + .expect("set permission profile"); }); builder.build(server).await } @@ -104,10 +99,13 @@ pub async fn build_unified_exec_zsh_fork_test( where F: FnOnce(&Path) + Send + 'static, { - let mut builder = test_codex() + let mut builder = zsh_fork_test_builder(runtime, approval_policy) .with_pre_build_hook(pre_build_hook) .with_config(move |config| { - runtime.apply_to_config(config, approval_policy, permission_profile); + config + .permissions + .set_permission_profile(permission_profile) + .expect("set permission profile"); config.use_experimental_unified_exec_tool = true; config .features @@ -121,6 +119,15 @@ where builder.build(server).await } +pub fn zsh_fork_test_builder( + runtime: ZshForkRuntime, + approval_policy: AskForApproval, +) -> TestCodexBuilder { + test_codex().with_config(move |config| { + runtime.apply_to_config(config, approval_policy); + }) +} + fn find_test_zsh_path() -> Result> { let repo_root = codex_utils_cargo_bin::repo_root()?; let dotslash_zsh = repo_root.join("codex-rs/app-server/tests/suite/zsh"); diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f0ab48c5e16c..54205f7e8b7e 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -10,6 +10,7 @@ use codex_features::Feature; use codex_protocol::approvals::NetworkApprovalProtocol; use codex_protocol::approvals::NetworkPolicyAmendment; use codex_protocol::approvals::NetworkPolicyRuleAction; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; @@ -47,6 +48,7 @@ use core_test_support::wait_for_event_with_timeout; use core_test_support::zsh_fork::build_zsh_fork_test; use core_test_support::zsh_fork::restrictive_workspace_write_profile; use core_test_support::zsh_fork::zsh_fork_runtime; +use core_test_support::zsh_fork::zsh_fork_test_builder; use pretty_assertions::assert_eq; use regex_lite::Regex; use serde_json::Value; @@ -131,6 +133,8 @@ enum ActionKind { const DEFAULT_UNIFIED_EXEC_JUSTIFICATION: &str = "Requires escalated permissions to bypass the sandbox in tests."; +const WORKSPACE_PERMISSION_PROFILE_CONFIG: &str = r#"default_permissions = ":workspace" +"#; impl ActionKind { fn policy_src(&self) -> Option<&'static str> { @@ -640,6 +644,7 @@ enum ScenarioGroup { UnifiedExec, } +#[derive(Debug, Eq, PartialEq)] struct CommandResult { exit_code: Option, stdout: String, @@ -683,6 +688,52 @@ async fn submit_turn( Ok(()) } +async fn submit_turn_preserving_active_permission_profile( + test: &TestCodex, + prompt: &str, + approval_policy: AskForApproval, +) -> Result<()> { + let session_model = test.session_configured.model.clone(); + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: prompt.into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + environments: Some(local_selections(test.config.cwd.clone())), + approval_policy: Some(approval_policy), + approvals_reviewer: Some(ApprovalsReviewer::User), + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: session_model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, + }) + .await?; + + Ok(()) +} + +fn assert_active_workspace_permission_profile(test: &TestCodex) { + assert_eq!( + test.session_configured + .active_permission_profile + .as_ref() + .map(|profile| profile.id.as_str()), + Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE) + ); +} + fn parse_result(item: &Value) -> CommandResult { let output_str = item .get("output") @@ -2696,6 +2747,325 @@ async fn matched_prefix_rule_runs_unsandboxed_under_zsh_fork() -> Result<()> { Ok(()) } +/// Verifies that an allowlisted script retains the originating named profile +/// when its shell tool call requests escalated, unsandboxed execution. +/// +/// Tool owners use this pattern when a trusted wrapper must run outside the +/// current sandbox, but then needs to launch child commands back inside the +/// same sandbox with `codex sandbox -P`. The nested invocation must also pass +/// `--include-managed-config` so it continues to honor enterprise requirements. +/// The test proves both halves of that contract: the wrapper writes outside the +/// `:workspace` sandbox, while its inherited profile name remains `:workspace`. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[cfg(unix)] +async fn allowed_escalated_shell_command_inherits_active_permission_profile() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let home = Arc::new(TempDir::new()?); + fs::write( + home.path().join("config.toml"), + WORKSPACE_PERMISSION_PROFILE_CONFIG, + )?; + + let script_dir = tempfile::tempdir_in(std::env::current_dir()?)?; + let script_path = script_dir.path().join("print-permission-profile.sh"); + let outside_path = script_dir.path().join("unsandboxed-marker"); + fs::write( + &script_path, + format!( + r#"#!/bin/sh +# Print the inherited profile so the test can verify that it reached this script. +printenv CODEX_PERMISSION_PROFILE +touch {outside_path:?} +"# + ), + )?; + + let rules_dir = home.path().join("rules"); + fs::create_dir_all(&rules_dir)?; + let script_pattern = serde_json::to_string(&script_path.to_string_lossy())?; + fs::write( + rules_dir.join("default.rules"), + format!(r#"prefix_rule(pattern=["/bin/sh", {script_pattern}], decision="allow")"#), + )?; + + let approval_policy = AskForApproval::OnRequest; + let mut builder = test_codex().with_home(home).with_config(move |config| { + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + }); + let test = builder.build(&server).await?; + assert!(!outside_path.starts_with(test.config.cwd.as_path())); + assert_active_workspace_permission_profile(&test); + + let call_id = "allowed-escalated-shell-inherits-permission-profile"; + let command = format!("/bin/sh {script_path:?}"); + let event = shell_event( + call_id, + &command, + /*timeout_ms*/ 5_000, + SandboxPermissions::RequireEscalated, + )?; + let _ = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-escalated-profile-1"), + event, + ev_completed("resp-escalated-profile-1"), + ]), + ) + .await; + let results = mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-escalated-profile-1", "done"), + ev_completed("resp-escalated-profile-2"), + ]), + ) + .await; + + submit_turn_preserving_active_permission_profile( + &test, + "run the allowed script with escalated permissions", + approval_policy, + ) + .await?; + + wait_for_completion_without_approval(&test).await; + + let result = parse_result(&results.single_request().function_call_output(call_id)); + assert_eq!( + result, + CommandResult { + exit_code: Some(0), + stdout: format!("{BUILT_IN_PERMISSION_PROFILE_WORKSPACE}\n"), + }, + "the unsandboxed script should inherit CODEX_PERMISSION_PROFILE from the shell command" + ); + assert!( + outside_path.exists(), + "allowed escalated script should run outside the :workspace sandbox" + ); + + Ok(()) +} + +/// Verifies that zsh-fork applies an inner script's allow rule even when the +/// model invokes an outer wrapper, and that the escalated script retains the +/// named profile needed to reconstruct the original sandbox remotely without +/// dropping managed enterprise requirements. The script treats the inherited +/// environment value as untrusted and accepts only explicitly allowlisted +/// profile names before passing one to `codex sandbox -P`. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[cfg(unix)] +async fn zsh_fork_inner_allowed_script_inherits_active_permission_profile() -> Result<()> { + skip_if_no_network!(Ok(())); + + let Some(runtime) = zsh_fork_runtime("zsh-fork remote sandbox wrapper test")? else { + return Ok(()); + }; + + const HOST: &str = "builder.example.com"; + let approval_policy = AskForApproval::OnRequest; + let script_dir = tempfile::tempdir_in(std::env::current_dir()?)?; + let wrapper_path = script_dir.path().join("remote-bash"); + let remote_bash_path = script_dir.path().join("remote_bash.py"); + let outside_path = script_dir.path().join("remote-bash-unsandboxed-marker"); + let outside_path_literal = serde_json::to_string(&outside_path.to_string_lossy())?; + fs::write( + &remote_bash_path, + format!( + r#"#!/usr/bin/env python3 +import argparse +import os +from pathlib import Path +import re +import shlex +import sys + +ALLOWED_HOSTS = ("builder.example.com",) +ALLOWED_PROFILES = (":workspace",) + + +def parse_args(): + parser = argparse.ArgumentParser( + description="Print an ssh command that recreates the current Codex sandbox remotely." + ) + parser.add_argument("--host", required=True) + try: + separator = sys.argv.index("--") + except ValueError: + parser.error("the remote command must follow --") + args = parser.parse_args(sys.argv[1:separator]) + args.command = sys.argv[separator + 1:] + if not args.command: + parser.error("the remote command must not be empty") + if not re.fullmatch(r"[a-z0-9.-]+", args.host) or args.host not in ALLOWED_HOSTS: + parser.error("host is not allowlisted") + return args + + +def main(): + args = parse_args() + profile_name = os.environ.get("CODEX_PERMISSION_PROFILE") + if not profile_name: + raise SystemExit("CODEX_PERMISSION_PROFILE must not be empty") + if profile_name not in ALLOWED_PROFILES: + raise SystemExit("CODEX_PERMISSION_PROFILE is not allowlisted") + + shell_command = shlex.join(args.command) + sandbox_command = shlex.join( + [ + "codex", + "sandbox", + "-P", + profile_name, + "--include-managed-config", + "--", + "bash", + "-lc", + shell_command, + ] + ) + print(shlex.join(["ssh", args.host, sandbox_command])) + + # Test-only proof that this inner script was allowed to run unsandboxed. + Path({outside_path_literal}).write_text("unsandboxed", encoding="utf-8") + + +if __name__ == "__main__": + main() +"# + ), + )?; + let remote_bash_exec = shlex::try_join([remote_bash_path.to_string_lossy().as_ref()])?; + fs::write( + &wrapper_path, + format!( + r#"#!/usr/bin/env zsh +exec {remote_bash_exec} "$@" +"# + ), + )?; + for path in [&wrapper_path, &remote_bash_path] { + let mut permissions = fs::metadata(path)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions)?; + } + + let remote_bash_pattern = serde_json::to_string(&remote_bash_path.to_string_lossy())?; + let rules = format!(r#"prefix_rule(pattern=[{remote_bash_pattern}], decision="allow")"#); + let server = start_mock_server().await; + let mut builder = + zsh_fork_test_builder(runtime, approval_policy).with_pre_build_hook(move |home| { + fs::write( + home.join("config.toml"), + WORKSPACE_PERMISSION_PROFILE_CONFIG, + ) + .expect("write config"); + let rules_dir = home.join("rules"); + fs::create_dir_all(&rules_dir).expect("create rules dir"); + fs::write(rules_dir.join("default.rules"), rules).expect("write rules"); + }); + let test = builder.build(&server).await?; + assert!(!outside_path.starts_with(test.config.cwd.as_path())); + assert_active_workspace_permission_profile(&test); + + let command = shlex::try_join([ + wrapper_path.to_string_lossy().as_ref(), + "--host", + HOST, + "--", + "printf", + "%s", + "hello world", + ])?; + let call_id = "zsh-fork-remote-sandbox-wrapper"; + let event = shell_event( + call_id, + &command, + /*timeout_ms*/ 30_000, + SandboxPermissions::UseDefault, + )?; + let _ = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-zsh-fork-remote-wrapper-1"), + event, + ev_completed("resp-zsh-fork-remote-wrapper-1"), + ]), + ) + .await; + let results = mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-zsh-fork-remote-wrapper-1", "done"), + ev_completed("resp-zsh-fork-remote-wrapper-2"), + ]), + ) + .await; + + submit_turn_preserving_active_permission_profile( + &test, + "run the remote sandbox wrapper", + approval_policy, + ) + .await?; + wait_for_completion_without_approval(&test).await; + + let result = parse_result(&results.single_request().function_call_output(call_id)); + assert_eq!( + result.exit_code.unwrap_or(0), + 0, + "the inner remote_bash.py script should run successfully: {}", + result.stdout + ); + let ssh_argv = shlex::split(result.stdout.trim()).context("parse printed ssh command")?; + assert_eq!( + ssh_argv.len(), + 3, + "expected ssh HOST LONG_COMMAND, got: {}", + result.stdout + ); + assert_eq!( + ssh_argv[..2], + ["ssh", HOST], + "remote_bash.py should target only the allowlisted host" + ); + let sandbox_argv = shlex::split(&ssh_argv[2]).context("parse remote sandbox command")?; + assert_eq!( + sandbox_argv.len(), + 9, + "expected codex sandbox ... bash -lc CMD" + ); + assert_eq!( + sandbox_argv[..8], + [ + "codex", + "sandbox", + "-P", + BUILT_IN_PERMISSION_PROFILE_WORKSPACE, + "--include-managed-config", + "--", + "bash", + "-lc", + ], + "remote_bash.py should use the allowlisted inherited profile and managed configuration to reconstruct the Codex sandbox" + ); + let command_argv = shlex::split(&sandbox_argv[8]).context("parse remote bash command")?; + assert_eq!( + command_argv, + ["printf", "%s", "hello world"], + "remote_bash.py should preserve every argument after --" + ); + assert!( + outside_path.exists(), + "the inner allowlisted script should run outside the :workspace sandbox" + ); + + Ok(()) +} + #[tokio::test(flavor = "current_thread")] #[cfg(unix)] async fn invalid_requested_prefix_rule_falls_back_for_compound_command() -> Result<()> {