diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index 28b894c911..ffcdc48a33 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -345,6 +345,7 @@ fn resolved_local_environments( PermissionProfile::read_only(), ), shell_environment_policy: Default::default(), + exec_policy: None, selected_capability_roots: Vec::new(), }), }, diff --git a/codex-rs/core/src/environment_selection.rs b/codex-rs/core/src/environment_selection.rs index a67d0ba0d7..4a5c1332c5 100644 --- a/codex-rs/core/src/environment_selection.rs +++ b/codex-rs/core/src/environment_selection.rs @@ -787,6 +787,7 @@ mod tests { allow_login_shell: true, permission_profile: PermissionProfileSnapshot::legacy(PermissionProfile::read_only()), shell_environment_policy: Default::default(), + exec_policy: None, selected_capability_roots: Vec::new(), } } @@ -959,6 +960,7 @@ url = "ws://127.0.0.1:8765" vec![cwd.join("profile-root")], ), shell_environment_policy: Default::default(), + exec_policy: None, selected_capability_roots: Vec::new(), }; let turn_environments = ThreadEnvironments::new( @@ -1161,6 +1163,7 @@ url = "ws://127.0.0.1:8765" vec![cwd.join("profile-root")], ), shell_environment_policy: Default::default(), + exec_policy: None, selected_capability_roots: Vec::new(), }; let cwd = PathUri::from_abs_path(&cwd); @@ -1495,6 +1498,7 @@ url = "ws://127.0.0.1:8765" vec![cwd.join("child-profile-root")], ), shell_environment_policy: Default::default(), + exec_policy: None, selected_capability_roots: Vec::new(), }; let environments = ThreadEnvironments::new( @@ -1539,6 +1543,7 @@ url = "ws://127.0.0.1:8765" allow_login_shell: false, permission_profile: PermissionProfileSnapshot::legacy(PermissionProfile::read_only()), shell_environment_policy: Default::default(), + exec_policy: None, selected_capability_roots: vec![root("parent-root")], }; let mut owner_selection = selection.clone(); diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 5de0593753..692b461c35 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -15,6 +15,7 @@ use codex_execpolicy::MatchOptions; use codex_execpolicy::NetworkRuleProtocol; use codex_execpolicy::Policy; use codex_execpolicy::PolicyParser; +use codex_execpolicy::RequirementsExecPolicy; use codex_execpolicy::RuleMatch; use codex_execpolicy::blocking_append_allow_prefix_rule; use codex_execpolicy::blocking_append_network_rule; @@ -282,6 +283,7 @@ pub(crate) struct ExecApprovalRequest<'a> { pub(crate) command: &'a [String], pub(crate) approval_policy: AskForApproval, pub(crate) permission_profile: PermissionProfile, + pub(crate) environment_policy: Option<&'a RequirementsExecPolicy>, pub(crate) windows_sandbox_level: WindowsSandboxLevel, pub(crate) sandbox_permissions: SandboxPermissions, pub(crate) prefix_rule: Option>, @@ -317,12 +319,13 @@ impl ExecPolicyManager { command, approval_policy, permission_profile, + environment_policy, windows_sandbox_level, sandbox_permissions, prefix_rule, allow_prefix_rules, } = req; - let exec_policy = self.current_for_prefix_rules(allow_prefix_rules); + let exec_policy = self.current_for_environment(environment_policy, allow_prefix_rules); let ExecPolicyCommands { commands, used_complex_parsing, diff --git a/codex-rs/core/src/exec_policy/model_policy.rs b/codex-rs/core/src/exec_policy/model_policy.rs index c1c2df1938..9ca8bc200e 100644 --- a/codex-rs/core/src/exec_policy/model_policy.rs +++ b/codex-rs/core/src/exec_policy/model_policy.rs @@ -2,6 +2,7 @@ use super::ExecPolicyManager; use codex_execpolicy::Decision; use codex_execpolicy::Policy; use codex_execpolicy::PrefixRule; +use codex_execpolicy::RequirementsExecPolicy; use std::sync::Arc; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -11,6 +12,18 @@ pub(crate) enum AllowPrefixRules { } impl ExecPolicyManager { + pub(crate) fn current_for_environment( + &self, + environment_policy: Option<&RequirementsExecPolicy>, + allow_prefix_rules: AllowPrefixRules, + ) -> Arc { + let policy = self.current_for_prefix_rules(allow_prefix_rules); + match environment_policy { + Some(environment_policy) => Arc::new(policy.merge_overlay(environment_policy.as_ref())), + None => policy, + } + } + pub(crate) fn current_for_prefix_rules( &self, allow_prefix_rules: AllowPrefixRules, diff --git a/codex-rs/core/src/exec_policy/model_policy_tests.rs b/codex-rs/core/src/exec_policy/model_policy_tests.rs index ae103a17ff..9541513435 100644 --- a/codex-rs/core/src/exec_policy/model_policy_tests.rs +++ b/codex-rs/core/src/exec_policy/model_policy_tests.rs @@ -7,6 +7,7 @@ use codex_execpolicy::Decision; use codex_execpolicy::MatchOptions; use codex_execpolicy::Policy; use codex_execpolicy::PolicyParser; +use codex_execpolicy::RequirementsExecPolicy; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; @@ -86,6 +87,35 @@ fn cyber_policy_filters_allow_prefixes_but_preserves_restrictive_and_network_rul ); } +#[test] +fn environment_restrictions_apply_after_model_prefix_filtering() { + let (thread_policy, _) = policy_with_broad_allow_prefix(); + let manager = ExecPolicyManager::new(thread_policy); + let mut environment_policy = Policy::empty(); + environment_policy + .add_prefix_rule( + &["cargo".to_string(), "install".to_string()], + Decision::Forbidden, + ) + .expect("add environment restriction"); + let environment_policy = RequirementsExecPolicy::new(environment_policy); + let command = vec!["cargo".to_string(), "install".to_string()]; + + for allow_prefix_rules in [ + AllowPrefixRules::Honor, + AllowPrefixRules::IgnoreForCyberModel, + ] { + let policy = manager.current_for_environment(Some(&environment_policy), allow_prefix_rules); + assert_eq!( + policy.check(&command, &|_| Decision::Allow).decision, + Decision::Forbidden, + ); + if allow_prefix_rules == AllowPrefixRules::IgnoreForCyberModel { + assert!(policy.get_allowed_prefixes().is_empty()); + } + } +} + #[tokio::test] async fn cyber_policy_requires_approval_for_broad_wrapped_and_resolved_prefixes() { let (policy, program_path) = policy_with_broad_allow_prefix(); @@ -110,6 +140,7 @@ async fn cyber_policy_requires_approval_for_broad_wrapped_and_resolved_prefixes( command: &command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), + environment_policy: None, windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]), @@ -142,6 +173,7 @@ async fn cyber_policy_keeps_heuristically_safe_commands_inside_the_sandbox() { command: &command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), + environment_policy: None, windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -161,6 +193,7 @@ async fn cyber_policy_keeps_heuristically_safe_commands_inside_the_sandbox() { command: &command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), + environment_policy: None, windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, diff --git a/codex-rs/core/src/exec_policy_tests.rs b/codex-rs/core/src/exec_policy_tests.rs index 209a34f4b3..e7dfba8506 100644 --- a/codex-rs/core/src/exec_policy_tests.rs +++ b/codex-rs/core/src/exec_policy_tests.rs @@ -1389,6 +1389,7 @@ async fn mixed_rule_and_sandbox_prompt_prioritizes_rule_for_rejection_decision() mcp_elicitations: true, }), permission_profile: PermissionProfile::read_only(), + environment_policy: None, windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, @@ -1427,6 +1428,7 @@ async fn forced_rm_preserves_rule_rejection_when_granular_rules_are_disabled() { mcp_elicitations: true, }), permission_profile: PermissionProfile::read_only(), + environment_policy: None, windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, @@ -1452,6 +1454,7 @@ async fn exec_approval_requirement_falls_back_to_heuristics() { command: &command, approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), + environment_policy: None, windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1478,6 +1481,7 @@ async fn empty_bash_lc_script_falls_back_to_original_command() { command: &command, approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), + environment_policy: None, windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1508,6 +1512,7 @@ async fn whitespace_bash_lc_script_falls_back_to_original_command() { command: &command, approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), + environment_policy: None, windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1538,6 +1543,7 @@ async fn request_rule_uses_prefix_rule() { command: &command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), + environment_policy: None, windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]), @@ -1571,6 +1577,7 @@ async fn request_rule_falls_back_when_prefix_rule_does_not_approve_all_commands( command: &command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, + environment_policy: None, windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]), @@ -1611,6 +1618,7 @@ async fn heuristics_apply_when_other_commands_match_policy() { command: &command, approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::Disabled, + environment_policy: None, windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -2090,6 +2098,7 @@ async fn forced_rm_requires_approval_or_specific_rejection_on_all_platforms() { command: &dangerous_command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), + environment_policy: None, windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: permissions, prefix_rule: None, @@ -2112,6 +2121,7 @@ async fn forced_rm_requires_approval_or_specific_rejection_on_all_platforms() { command: &dangerous_command, approval_policy: AskForApproval::Never, permission_profile: PermissionProfile::read_only(), + environment_policy: None, windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: permissions, prefix_rule: None, @@ -2171,6 +2181,7 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() { command: &sneaky_command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), + environment_policy: None, windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: permissions, prefix_rule: None, @@ -2264,6 +2275,7 @@ async fn exec_approval_requirement_for_command( command: &command, approval_policy, permission_profile, + environment_policy: None, windows_sandbox_level: WindowsSandboxLevel::RestrictedToken, sandbox_permissions, prefix_rule, diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 70dd201d78..5867e3aa92 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -1121,6 +1121,7 @@ async fn mcp_sandbox_cwd_uses_matching_server_environment_uri() -> anyhow::Resul .permission_profile_state() .snapshot(), shell_environment_policy: Default::default(), + exec_policy: None, selected_capability_roots: Vec::new(), }), }, diff --git a/codex-rs/core/src/session/environment.rs b/codex-rs/core/src/session/environment.rs index 8d4019ae95..c9365b47a5 100644 --- a/codex-rs/core/src/session/environment.rs +++ b/codex-rs/core/src/session/environment.rs @@ -39,6 +39,15 @@ fn validate_environment_config( "environment readiness contains more than {MAX_SELECTED_CAPABILITY_ROOTS} selected capability roots" ))); } + if config + .exec_policy + .as_ref() + .is_some_and(|policy| !policy.as_ref().get_allowed_prefixes().is_empty()) + { + return Err(CodexErr::InvalidRequest( + "environment command policy cannot contain allow rules".to_string(), + )); + } let mut root_ids = HashSet::with_capacity(config.selected_capability_roots.len()); for root in &config.selected_capability_roots { diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 9d5ff5de6a..1bf01294a7 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -144,6 +144,7 @@ impl SessionConfiguration { allow_login_shell: self.allow_login_shell, permission_profile: self.permission_profile_state.snapshot(), shell_environment_policy: self.shell_environment_policy.clone(), + exec_policy: None, selected_capability_roots: Vec::new(), } } diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 7146d2a1e6..cdd0f70604 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -11328,6 +11328,7 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { command: &command, approval_policy: turn_context.approval_policy(), permission_profile: turn_context.permission_profile(), + environment_policy: None, windows_sandbox_level: turn_context.windows_sandbox_level, sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, diff --git a/codex-rs/core/src/tools/approvals.rs b/codex-rs/core/src/tools/approvals.rs index b4b5ae9651..0fb457a112 100644 --- a/codex-rs/core/src/tools/approvals.rs +++ b/codex-rs/core/src/tools/approvals.rs @@ -703,7 +703,19 @@ impl Session { .clone() .or_else(|| ctx.approval_reason.clone()) .or_else(|| justification.clone()); - with_cached_approval(&self.services, tool_name, action.cache_keys(), || async { + let policy_fingerprint = ctx + .review_context + .environments() + .turn_environments() + .find(|environment| environment.selection.environment_id == *environment_id) + .and_then(|environment| environment.config().exec_policy.as_ref()) + .map(codex_execpolicy::RequirementsExecPolicy::fingerprint); + let cache_keys = action + .cache_keys() + .into_iter() + .map(|key| (key, &policy_fingerprint)) + .collect(); + with_cached_approval(&self.services, tool_name, cache_keys, || async { self.request_command_approval( ctx.review_context.turn(), ctx.call_id.clone(), diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index ffd35d5a56..accd129e33 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -179,6 +179,7 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result crate::session::turn_context:: PermissionProfile::read_only(), ), shell_environment_policy: Default::default(), + exec_policy: None, selected_capability_roots: Vec::new(), }), }, diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 621cd83f32..b549156c2d 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -183,7 +183,10 @@ pub(super) async fn try_run_zsh_fork( ctx.session .services .exec_policy - .current_for_prefix_rules(ctx.step_context.turn.allow_prefix_rules()) + .current_for_environment( + req.turn_environment.config().exec_policy.as_ref(), + ctx.step_context.turn.allow_prefix_rules(), + ) .as_ref() .clone(), )); @@ -297,7 +300,10 @@ pub(crate) async fn prepare_unified_exec_zsh_fork( ctx.session .services .exec_policy - .current_for_prefix_rules(ctx.step_context.turn.allow_prefix_rules()) + .current_for_environment( + req.turn_environment.config().exec_policy.as_ref(), + ctx.step_context.turn.allow_prefix_rules(), + ) .as_ref() .clone(), )); diff --git a/codex-rs/core/src/tools/runtimes/shell_tests.rs b/codex-rs/core/src/tools/runtimes/shell_tests.rs index 598370ddfe..cead75495b 100644 --- a/codex-rs/core/src/tools/runtimes/shell_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell_tests.rs @@ -28,6 +28,7 @@ async fn approval_key_uses_path_uri_and_includes_environment_id() { PermissionProfile::read_only(), ), shell_environment_policy: Default::default(), + exec_policy: None, 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 0bfce9bf1d..099dbd49b5 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -543,6 +543,7 @@ mod tests { PermissionProfile::read_only(), ), shell_environment_policy: Default::default(), + exec_policy: None, 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 7c443e70ee..62d784f49e 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -917,6 +917,7 @@ async fn zsh_fork_unified_exec_keeps_shell_parameter_when_remote_environment_ava .permission_profile_state() .snapshot(), shell_environment_policy: Default::default(), + exec_policy: None, 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 5aa46e8eac..8b2924ffbd 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -1246,6 +1246,7 @@ impl UnifiedExecProcessManager { command: &request.command, approval_policy: turn.approval_policy(), permission_profile: request.turn_environment.permission_profile().clone(), + environment_policy: request.turn_environment.config().exec_policy.as_ref(), windows_sandbox_level: turn.windows_sandbox_level, sandbox_permissions: if request.additional_permissions_preapproved { crate::sandboxing::SandboxPermissions::UseDefault diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index aad7a5de93..1232dc0e17 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -2,12 +2,20 @@ use anyhow::Result; use codex_config::test_support::CloudConfigBundleFixture; +use codex_core::EnvironmentConfig; use codex_core::TurnInputRequest; +use codex_core::config::Constrained; +use codex_execpolicy::Decision; +use codex_execpolicy::Policy; +use codex_execpolicy::RequirementsExecPolicy; use codex_features::Feature; +use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; +use codex_protocol::error::CodexErrorDetails; use codex_protocol::models::PermissionProfile; +use codex_protocol::models::PermissionProfileSnapshot; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::GranularApprovalConfig; @@ -524,6 +532,227 @@ prefix_rules = [ Ok(()) } +#[tokio::test] +async fn environment_command_restrictions_override_saved_prefix_approvals() -> Result<()> { + skip_if_target_windows!( + Ok(()), + "managed prefix fixture uses POSIX executable semantics" + ); + + let mut builder = test_codex().with_config(|config| { + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); + let policy_path = config.codex_home.join("rules").join("approved.rules"); + fs::create_dir_all(policy_path.parent().expect("rules directory")) + .expect("create rules directory"); + fs::write( + policy_path, + r#"prefix_rule(pattern=["echo"], decision="allow")"#, + ) + .expect("write approved command prefix"); + }); + let server = start_mock_server().await; + let test = builder.build_with_auto_env(&server).await?; + let selection = test + .codex + .environment_selections() + .await + .into_iter() + .next() + .expect("thread should select its executor environment"); + let mut invalid_policy = Policy::empty(); + invalid_policy.add_prefix_rule(&["echo".to_string()], Decision::Allow)?; + let error = test + .codex + .environment_ready( + &selection, + EnvironmentConfig { + allow_login_shell: true, + permission_profile: PermissionProfileSnapshot::legacy(PermissionProfile::Disabled), + shell_environment_policy: Default::default(), + exec_policy: Some(RequirementsExecPolicy::new(invalid_policy)), + selected_capability_roots: Vec::new(), + }, + ) + .await + .expect_err("environment policies must not introduce command allowances"); + assert!(matches!( + error.details(), + CodexErrorDetails::InvalidRequest(message) + if message == "environment command policy cannot contain allow rules" + )); + + let mut environment_policy = Policy::empty(); + environment_policy.add_prefix_rule(&["echo".to_string()], Decision::Forbidden)?; + test.codex + .environment_ready( + &selection, + EnvironmentConfig { + allow_login_shell: true, + permission_profile: PermissionProfileSnapshot::legacy(PermissionProfile::Disabled), + shell_environment_policy: Default::default(), + exec_policy: Some(RequirementsExecPolicy::new(environment_policy)), + selected_capability_roots: Vec::new(), + }, + ) + .await?; + + let call_id = "environment-managed-command"; + let args = json!({ + "cmd": "echo blocked", + "yield_time_ms": 1_000, + }); + mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-environment-managed-1"), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), + ev_completed("resp-environment-managed-1"), + ]), + ) + .await; + let results_mock = mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-environment-managed", "done"), + ev_completed("resp-environment-managed-2"), + ]), + ) + .await; + + test.submit_turn_with_approval_and_permission_profile( + "run shell command", + AskForApproval::Never, + PermissionProfile::Disabled, + ) + .await?; + + let output_item = results_mock.single_request().function_call_output(call_id); + let output = output_item + .get("output") + .and_then(Value::as_str) + .expect("function call output should include a string output payload"); + assert!( + output.contains("policy forbids commands starting with `echo`"), + "unexpected output: {output}" + ); + + Ok(()) +} + +#[tokio::test] +async fn environment_command_policy_changes_invalidate_session_approvals() -> Result<()> { + skip_if_target_windows!( + Ok(()), + "managed prefix fixture uses POSIX executable semantics" + ); + + let mut builder = test_codex().with_config(|config| { + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); + config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); + config + .permissions + .set_permission_profile(PermissionProfile::Disabled) + .expect("test config should allow unrestricted permissions"); + config.approvals_reviewer = ApprovalsReviewer::User; + let policy_path = config.codex_home.join("rules").join("prompt.rules"); + fs::create_dir_all(policy_path.parent().expect("rules directory")) + .expect("create rules directory"); + fs::write( + policy_path, + r#"prefix_rule(pattern=["echo"], decision="prompt")"#, + ) + .expect("write prompt command prefix"); + }); + let server = start_mock_server().await; + let test = builder.build_with_auto_env(&server).await?; + let selection = test + .codex + .environment_selections() + .await + .into_iter() + .next() + .expect("thread should select its executor environment"); + + for (attempt, decision) in [ + ("before-owner-policy", ReviewDecision::ApprovedForSession), + ("after-owner-policy", ReviewDecision::Approved), + ] { + if attempt == "after-owner-policy" { + let mut policy = Policy::empty(); + policy.add_prefix_rule(&["echo".to_string()], Decision::Prompt)?; + test.codex + .environment_ready( + &selection, + EnvironmentConfig { + allow_login_shell: true, + permission_profile: PermissionProfileSnapshot::legacy( + PermissionProfile::Disabled, + ), + shell_environment_policy: Default::default(), + exec_policy: Some(RequirementsExecPolicy::new(policy)), + selected_capability_roots: Vec::new(), + }, + ) + .await?; + } + + let args = json!({ "cmd": "echo approval", "yield_time_ms": 1_000 }); + mount_sse_once( + &server, + sse(vec![ + ev_response_created(attempt), + ev_function_call(attempt, "exec_command", &serde_json::to_string(&args)?), + ev_completed(attempt), + ]), + ) + .await; + mount_sse_once( + &server, + sse(vec![ + ev_assistant_message(attempt, "done"), + ev_completed(attempt), + ]), + ) + .await; + + test.codex + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: attempt.into(), + text_elements: Vec::new(), + }])) + .await?; + let event = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) + ) + }) + .await; + let EventMsg::ExecApprovalRequest(approval) = event else { + panic!("expected a fresh command approval {attempt}"); + }; + test.codex + .submit(Op::ExecApproval { + id: approval.effective_approval_id(), + turn_id: None, + decision, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + } + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn shell_command_empty_script_with_collaboration_mode_does_not_panic() -> Result<()> { let server = start_mock_server().await; diff --git a/codex-rs/core/tests/suite/guardian_review.rs b/codex-rs/core/tests/suite/guardian_review.rs index e40be262f8..2c24ff955b 100644 --- a/codex-rs/core/tests/suite/guardian_review.rs +++ b/codex-rs/core/tests/suite/guardian_review.rs @@ -598,6 +598,7 @@ async fn guardian_session_is_reused_for_consecutive_tool_reviews_without_prewarm test.config.permissions.permission_profile().clone(), ), shell_environment_policy: Default::default(), + exec_policy: None, 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 b62fd5f3b8..eae4516af7 100644 --- a/codex-rs/core/tests/suite/mcp_tool_exposure.rs +++ b/codex-rs/core/tests/suite/mcp_tool_exposure.rs @@ -483,6 +483,7 @@ async fn root_reconciliation_reuses_pending_apps_startup() -> Result<()> { test.config.permissions.permission_profile().clone(), ), shell_environment_policy: Default::default(), + exec_policy: None, 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 88a39478bb..55b0bb5266 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -551,6 +551,7 @@ async fn environment_permissions_follow_configuration_ownership() -> Result<()> PermissionProfile::read_only(), ), shell_environment_policy: Default::default(), + exec_policy: None, selected_capability_roots: Vec::new(), }), ..selection @@ -1173,6 +1174,7 @@ async fn shared_executor_keeps_ready_capability_roots_scoped_to_each_attachment( allow_login_shell: false, permission_profile: permission_profile.clone(), shell_environment_policy: Default::default(), + exec_policy: None, selected_capability_roots: vec![root("duplicate"), root("duplicate")], }), ] { @@ -1208,6 +1210,7 @@ async fn shared_executor_keeps_ready_capability_roots_scoped_to_each_attachment( allow_login_shell: true, permission_profile: permission_profile.clone(), shell_environment_policy: Default::default(), + exec_policy: None, selected_capability_roots: vec![root("startup-root"), root("second-root")], }), ..selection.clone() @@ -1227,6 +1230,7 @@ async fn shared_executor_keeps_ready_capability_roots_scoped_to_each_attachment( allow_login_shell: false, permission_profile: permission_profile.clone(), shell_environment_policy: Default::default(), + exec_policy: None, selected_capability_roots: vec![root("first-root")], }), ..selection.clone() @@ -1285,6 +1289,7 @@ async fn shared_executor_keeps_ready_capability_roots_scoped_to_each_attachment( allow_login_shell: false, permission_profile: permission_profile.clone(), shell_environment_policy: Default::default(), + exec_policy: None, selected_capability_roots: vec![root("first-updated-root")], }), ..selection.clone() @@ -1381,6 +1386,7 @@ async fn pending_attachment_installs_configuration_before_waiting_turn_resumes() allow_login_shell, permission_profile: PermissionProfileSnapshot::legacy(PermissionProfile::read_only()), shell_environment_policy: Default::default(), + exec_policy: None, selected_capability_roots: vec![root(id)], }; let start_pending_thread = || { @@ -1697,6 +1703,7 @@ async fn ready_before_selection_exposes_remote_tools_and_capability_context_afte test.config.permissions.permission_profile().clone(), ), shell_environment_policy: Default::default(), + exec_policy: None, 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 7395c6fee8..d57305a6ad 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -355,6 +355,7 @@ async fn exec_command_uses_installed_environment_shell_policy_with_explicit_over .clone(), ..Default::default() }, + exec_policy: None, selected_capability_roots: Vec::new(), }, ) diff --git a/codex-rs/execpolicy/src/policy.rs b/codex-rs/execpolicy/src/policy.rs index cf268ac6e7..78790517fd 100644 --- a/codex-rs/execpolicy/src/policy.rs +++ b/codex-rs/execpolicy/src/policy.rs @@ -41,11 +41,22 @@ impl RequirementsExecPolicy { pub fn new(policy: Policy) -> Self { Self { policy } } + + pub fn fingerprint(&self) -> Vec { + let mut entries = Vec::new(); + for (program, rules) in self.policy.rules().iter_all() { + for rule in rules { + entries.push(format!("{program}:{rule:?}")); + } + } + entries.sort(); + entries + } } impl PartialEq for RequirementsExecPolicy { fn eq(&self, other: &Self) -> bool { - policy_fingerprint(&self.policy) == policy_fingerprint(&other.policy) + self.fingerprint() == other.fingerprint() } } @@ -57,17 +68,6 @@ impl AsRef for RequirementsExecPolicy { } } -fn policy_fingerprint(policy: &Policy) -> Vec { - let mut entries = Vec::new(); - for (program, rules) in policy.rules().iter_all() { - for rule in rules { - entries.push(format!("{program}:{rule:?}")); - } - } - entries.sort(); - entries -} - impl Policy { pub fn new(rules_by_program: MultiMap) -> Self { Self::from_parts(rules_by_program, Vec::new(), HashMap::new()) diff --git a/codex-rs/protocol/Cargo.toml b/codex-rs/protocol/Cargo.toml index e0aa40ba26..d80a80f7f2 100644 --- a/codex-rs/protocol/Cargo.toml +++ b/codex-rs/protocol/Cargo.toml @@ -16,6 +16,7 @@ workspace = true chardetng = { workspace = true } chrono = { workspace = true, features = ["serde"] } codex-async-utils = { workspace = true } +codex-execpolicy = { workspace = true } codex-extension-items = { workspace = true } codex-http-client = { workspace = true } codex-network-proxy = { workspace = true } @@ -54,7 +55,6 @@ seccompiler = { workspace = true } [dev-dependencies] anyhow = { workspace = true } -codex-execpolicy = { workspace = true } pretty_assertions = { workspace = true } tempfile = { workspace = true } diff --git a/codex-rs/protocol/src/environment.rs b/codex-rs/protocol/src/environment.rs index 5624034c48..d9f74c3055 100644 --- a/codex-rs/protocol/src/environment.rs +++ b/codex-rs/protocol/src/environment.rs @@ -1,6 +1,7 @@ use crate::capabilities::SelectedCapabilityRoot; use crate::config_types::ShellEnvironmentPolicy; use crate::models::PermissionProfileSnapshot; +use codex_execpolicy::RequirementsExecPolicy; /// Configuration supplied for a thread's selected environment. #[allow(clippy::large_enum_variant)] @@ -25,6 +26,8 @@ pub struct EnvironmentConfig { pub permission_profile: PermissionProfileSnapshot, /// Controls which environment variables shell commands may inherit. pub shell_environment_policy: ShellEnvironmentPolicy, + /// Additional managed command restrictions for this environment attachment. + pub exec_policy: Option, /// Capability roots selected for this thread's environment attachment. pub selected_capability_roots: Vec, } @@ -36,6 +39,7 @@ impl std::fmt::Debug for EnvironmentConfig { .field("allow_login_shell", &self.allow_login_shell) .field("permission_profile", &self.permission_profile) .field("shell_environment_policy", &"") + .field("exec_policy", &self.exec_policy) .field("selected_capability_roots", &self.selected_capability_roots) .finish() }