mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
Enforce environment-specific command policies (#38942)
## What changed - Add an optional restrictive execution policy to `EnvironmentConfig` and merge it over the active command policy for shell execution and approval decisions. - Reject environment policies that contain allow rules, so environment configuration can only tighten command access. - Include the environment policy fingerprint in cached approval keys so policy changes require fresh session approvals. ## Testing - Cover restrictions overriding saved prefix approvals, policy changes invalidating session approvals, and environment overlays remaining effective when model-specific prefix filtering is active. GitOrigin-RevId: e94f877549ed5e0f577b8cb52f3c1872c58f029d
This commit is contained in:
@@ -345,6 +345,7 @@ fn resolved_local_environments<const N: usize>(
|
||||
PermissionProfile::read_only(),
|
||||
),
|
||||
shell_environment_policy: Default::default(),
|
||||
exec_policy: None,
|
||||
selected_capability_roots: Vec::new(),
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<Vec<String>>,
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Policy> {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -179,6 +179,7 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result<FunctionToolOutput, Func
|
||||
command: &exec_params.command,
|
||||
approval_policy: turn.approval_policy(),
|
||||
permission_profile: turn_environment.permission_profile().clone(),
|
||||
environment_policy: turn_environment.config().exec_policy.as_ref(),
|
||||
windows_sandbox_level: turn.windows_sandbox_level,
|
||||
sandbox_permissions: if effective_additional_permissions.permissions_preapproved {
|
||||
codex_protocol::models::SandboxPermissions::UseDefault
|
||||
|
||||
@@ -139,6 +139,7 @@ async fn shell_command_handler_to_exec_params_uses_selected_environment() {
|
||||
active_permission_profile.clone(),
|
||||
),
|
||||
shell_environment_policy: selected_shell_environment_policy.clone(),
|
||||
exec_policy: None,
|
||||
selected_capability_roots: Vec::new(),
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -31,6 +31,7 @@ fn test_turn_environment(environment_id: &str) -> crate::session::turn_context::
|
||||
PermissionProfile::read_only(),
|
||||
),
|
||||
shell_environment_policy: Default::default(),
|
||||
exec_policy: None,
|
||||
selected_capability_roots: Vec::new(),
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
));
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -543,6 +543,7 @@ mod tests {
|
||||
PermissionProfile::read_only(),
|
||||
),
|
||||
shell_environment_policy: Default::default(),
|
||||
exec_policy: None,
|
||||
selected_capability_roots: Vec::new(),
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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],
|
||||
}),
|
||||
}]),
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -41,11 +41,22 @@ impl RequirementsExecPolicy {
|
||||
pub fn new(policy: Policy) -> Self {
|
||||
Self { policy }
|
||||
}
|
||||
|
||||
pub fn fingerprint(&self) -> Vec<String> {
|
||||
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<Policy> for RequirementsExecPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
fn policy_fingerprint(policy: &Policy) -> Vec<String> {
|
||||
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<String, RuleRef>) -> Self {
|
||||
Self::from_parts(rules_by_program, Vec::new(), HashMap::new())
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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<RequirementsExecPolicy>,
|
||||
/// Capability roots selected for this thread's environment attachment.
|
||||
pub selected_capability_roots: Vec<SelectedCapabilityRoot>,
|
||||
}
|
||||
@@ -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", &"<redacted>")
|
||||
.field("exec_policy", &self.exec_policy)
|
||||
.field("selected_capability_roots", &self.selected_capability_roots)
|
||||
.finish()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user