Files
codex/codex-rs/core/tests/suite/exec_policy.rs
T
rhan-oai 615be37b2b Add host-inherited thread instructions and execution policies (#58406)
## What changed

- Add `StartThreadOptions` setters for captured thread instructions and execution policy snapshots. Captured instructions replace the live thread provider; empty text clears the thread contribution. Hosts must supply inherited settings again on cold resume.
- Install inherited policies in independent managers only when the destination ignores user and project rules, has no additional policy files, and has matching managed requirements. Preserve managed command restrictions, host executable mappings, and network denials, including restrictions that absolute-path rules could otherwise bypass.
- Reject inherited startup settings for isolated sessions and reject combining local and transported execution policies.

## Testing

Add coverage for policy inheritance through children and grandchildren, incompatible or weakened policies, absolute-path amendments, and captured instructions overriding or clearing live instructions on startup and resume. An execution integration test checks that an inherited allow rule runs without approval while a managed denial still blocks execution.

GitOrigin-RevId: c1147992fe5bb5ba4bcf981c08fcfcb6f6bca8a1
2026-10-30 18:20:57 +00:00

1213 lines
41 KiB
Rust

#![allow(clippy::unwrap_used)]
use anyhow::Result;
use codex_config::test_support::CloudConfigBundleFixture;
use codex_core::EnvironmentConfig;
use codex_core::TurnInputRequest;
use codex_core::config::Constrained;
use codex_core::windows_sandbox::WindowsSandboxLevelExt;
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::config_types::WindowsSandboxLevel;
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;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::ThreadSettingsOverrides;
use codex_protocol::user_input::UserInput;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_exec_command_call;
use core_test_support::responses::ev_function_call;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::sse_completed;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_target_windows;
use core_test_support::skip_if_wine_exec;
use core_test_support::test_codex::local_requests;
use core_test_support::test_codex::test_codex;
use core_test_support::test_codex::turn_permission_fields;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use std::fs;
const COMPLEX_FORCED_RM_COMMAND: &str = "for target in \"\"; do rm -rf \"$target\"; done";
fn collaboration_mode_for_model(model: String) -> CollaborationMode {
CollaborationMode {
mode: ModeKind::Default,
settings: Settings {
model,
reasoning_effort: None,
developer_instructions: Some("exercise approvals in collaboration mode".to_string()),
},
}
}
async fn submit_user_turn(
test: &core_test_support::test_codex::TestCodex,
prompt: &str,
approval_policy: AskForApproval,
permission_profile: PermissionProfile,
collaboration_mode: Option<CollaborationMode>,
) -> Result<()> {
let session_model = test.session_configured.model.clone();
let (sandbox_policy, permission_profile) =
turn_permission_fields(permission_profile, test.config.cwd.as_path());
test.codex
.start_or_steer_turn(
TurnInputRequest::user_input(vec![UserInput::Text {
text: prompt.into(),
text_elements: Vec::new(),
}])
.with_thread_settings(ThreadSettingsOverrides {
environments: Some(local_requests(test.config.cwd.clone())),
approval_policy: Some(approval_policy),
sandbox_policy: Some(sandbox_policy),
permission_profile,
collaboration_mode: collaboration_mode.or({
Some(CollaborationMode {
mode: ModeKind::Default,
settings: Settings {
model: session_model,
reasoning_effort: None,
developer_instructions: None,
},
})
}),
..Default::default()
}),
)
.await?;
Ok(())
}
fn assert_no_matched_rules_invariant(output_item: &Value) {
let output = output_item
.get("output")
.and_then(Value::as_str)
.expect("function call output should include a string output payload");
assert!(
!output.contains("invariant failed: matched_rules must be non-empty"),
"unexpected invariant panic surfaced in output: {output}"
);
}
#[tokio::test]
async fn git_status_requires_approval_under_unless_trusted() -> Result<()> {
skip_if_wine_exec!(Ok(()), "command approval requires host-native paths");
let server = start_mock_server().await;
let mut builder = test_codex().with_model("gpt-5.2").with_config(|config| {
config
.features
.enable(Feature::UnifiedExec)
.expect("test config should allow feature update");
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted);
config
.permissions
.set_permission_profile(PermissionProfile::workspace_write())
.expect("set workspace-write permissions");
config.approvals_reviewer = ApprovalsReviewer::User;
});
let test = builder.build_with_auto_env(&server).await?;
let call_id = "git-status-approval";
let args = json!({"cmd": "git status", "yield_time_ms": 1_000});
let initial_mock = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-git-status-1"),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("resp-git-status-1"),
]),
)
.await;
let results_mock = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-git-status-1", "done"),
ev_completed("resp-git-status-2"),
]),
)
.await;
test.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "check git status".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 {
let output = results_mock.function_call_output_text(call_id);
panic!(
"expected git status to request approval before turn completion; output: {output:?}"
);
};
assert_eq!(approval.call_id, call_id);
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: None,
decision: ReviewDecision::denied("git status was not approved"),
})
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
assert!(
initial_mock
.single_request()
.message_input_texts("user")
.iter()
.any(|text| text == "check git status")
);
let output = results_mock
.single_request()
.function_call_output_text(call_id)
.expect("shell command output");
assert!(output.contains("git status was not approved"), "{output}");
Ok(())
}
#[tokio::test]
async fn startup_migrates_default_policy_and_honors_ignore_rules() -> Result<()> {
const LEGACY_POLICY: &str = r#"prefix_rule(pattern=["rm"], decision="allow")
prefix_rule(pattern=["git", "status"], decision="allow")
"#;
const MIGRATED_POLICY: &str = r#"prefix_rule(pattern=["git", "status"], decision="allow")
"#;
const MIGRATION_MARKER_FILENAME: &str = ".sandbox_migration";
let server = start_mock_server().await;
let mut migrated_builder = test_codex().with_config(|config| {
let policy_path = config.codex_home.join("rules/default.rules");
fs::create_dir_all(policy_path.parent().expect("rules directory"))
.expect("create rules directory");
fs::write(policy_path, LEGACY_POLICY).expect("write legacy policy");
});
let migrated = migrated_builder.build_with_auto_env(&server).await?;
let migrated_policy_path = migrated.codex_home_path().join("rules/default.rules");
assert_eq!(fs::read_to_string(&migrated_policy_path)?, MIGRATED_POLICY);
assert_eq!(
fs::read_to_string(migrated.codex_home_path().join(MIGRATION_MARKER_FILENAME))?,
"v1\n"
);
let mut ignored_builder = test_codex().with_config(|config| {
let policy_path = config.codex_home.join("rules/default.rules");
fs::create_dir_all(policy_path.parent().expect("rules directory"))
.expect("create rules directory");
fs::write(policy_path, LEGACY_POLICY).expect("write legacy policy");
config.config_layer_stack = config
.config_layer_stack
.clone()
.with_user_and_project_exec_policy_rules_ignored(
/*ignore_user_and_project_exec_policy_rules*/ true,
);
});
let ignored = ignored_builder.build_with_auto_env(&server).await?;
let ignored_policy_path = ignored.codex_home_path().join("rules/default.rules");
assert_eq!(fs::read_to_string(&ignored_policy_path)?, LEGACY_POLICY);
assert!(
!ignored
.codex_home_path()
.join(MIGRATION_MARKER_FILENAME)
.exists()
);
Ok(())
}
#[tokio::test]
async fn granular_complex_forced_rm_denial_explains_why_the_command_was_rejected() -> Result<()> {
skip_if_target_windows!(Ok(()), "uses a POSIX shell command fixture");
let server = start_mock_server().await;
let mut builder = test_codex();
let test = builder.build_with_auto_env(&server).await?;
let call_id = "forced-rm-denied";
let args = json!({
"cmd": COMPLEX_FORCED_RM_COMMAND,
"yield_time_ms": 1_000,
});
mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-forced-rm-1"),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("resp-forced-rm-1"),
]),
)
.await;
let results_mock = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-forced-rm-1", "done"),
ev_completed("resp-forced-rm-2"),
]),
)
.await;
submit_user_turn(
&test,
"run the forced rm loop",
AskForApproval::Granular(GranularApprovalConfig {
sandbox_approval: false,
rules: true,
skill_approval: true,
request_permissions: true,
mcp_elicitations: true,
}),
PermissionProfile::read_only(),
/*collaboration_mode*/ None,
)
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.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("rm -f style commands are not permitted. Use a safer approach"),
"unexpected output: {output}"
);
Ok(())
}
#[tokio::test]
async fn granular_complex_forced_rm_requests_approval_when_allowed() -> Result<()> {
skip_if_target_windows!(Ok(()), "uses a POSIX shell command fixture");
let server = start_mock_server().await;
let mut builder = test_codex();
let test = builder.build_with_auto_env(&server).await?;
let call_id = "forced-rm-approval";
let args = json!({
"cmd": COMPLEX_FORCED_RM_COMMAND,
"yield_time_ms": 1_000,
});
mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-forced-rm-approval-1"),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("resp-forced-rm-approval-1"),
]),
)
.await;
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-forced-rm-approval-1", "done"),
ev_completed("resp-forced-rm-approval-2"),
]),
)
.await;
submit_user_turn(
&test,
"run the forced rm loop",
AskForApproval::Granular(GranularApprovalConfig {
sandbox_approval: true,
rules: true,
skill_approval: true,
request_permissions: true,
mcp_elicitations: true,
}),
PermissionProfile::read_only(),
/*collaboration_mode*/ None,
)
.await?;
let approval_event = wait_for_event(&test.codex, |event| {
matches!(
event,
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
)
})
.await;
let EventMsg::ExecApprovalRequest(approval) = approval_event else {
panic!("expected forced rm to request approval before turn completion");
};
assert_eq!(
approval.command.last().map(String::as_str),
Some(COMPLEX_FORCED_RM_COMMAND)
);
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: None,
decision: ReviewDecision::denied("rejected by user"),
})
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
Ok(())
}
#[tokio::test]
async fn deeply_nested_forced_rm_is_rejected_before_execution_when_approvals_are_disabled()
-> Result<()> {
skip_if_target_windows!(Ok(()), "uses a POSIX shell command fixture");
let server = start_mock_server().await;
let mut builder = test_codex();
let test = builder.build_with_auto_env(&server).await?;
let sentinel = test.config.cwd.join("forced-rm-sentinel");
fs::write(&sentinel, "must not be deleted")?;
let call_id = "deeply-nested-forced-rm";
let args = json!({
"cmd": "env env env env env env env env env rm -rf forced-rm-sentinel",
"yield_time_ms": 1_000,
});
mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-deeply-nested-forced-rm-1"),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("resp-deeply-nested-forced-rm-1"),
]),
)
.await;
let results_mock = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-deeply-nested-forced-rm", "done"),
ev_completed("resp-deeply-nested-forced-rm-2"),
]),
)
.await;
submit_user_turn(
&test,
"run the deeply nested forced rm",
AskForApproval::Never,
PermissionProfile::Disabled,
/*collaboration_mode*/ None,
)
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.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("rejected: blocked by policy"),
"unexpected output: {output}"
);
assert!(sentinel.exists(), "the rejected command must not execute");
Ok(())
}
#[cfg(windows)]
#[tokio::test]
async fn unified_exec_disabled_windows_sandbox_rejects_managed_read_only_command() -> Result<()> {
let server = start_mock_server().await;
let mut builder = test_codex().with_config(|config| {
config
.features
.enable(Feature::UnifiedExec)
.expect("test config should allow feature update");
config
.features
.disable(Feature::WindowsSandbox)
.expect("test config should allow feature update");
config
.features
.disable(Feature::WindowsSandboxElevated)
.expect("test config should allow feature update");
config.set_windows_sandbox_enabled(false);
config.set_windows_elevated_sandbox_enabled(false);
});
let test = builder.build(&server).await?;
let call_id = "unified-exec-disabled-windows-sandbox-read-only";
let args = json!({
"cmd": "cmd.exe /c dir",
"yield_time_ms": 1_000,
});
mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-disabled-windows-sandbox-1"),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("resp-disabled-windows-sandbox-1"),
]),
)
.await;
let results_mock = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-disabled-windows-sandbox-1", "done"),
ev_completed("resp-disabled-windows-sandbox-2"),
]),
)
.await;
submit_user_turn(
&test,
"run unified exec with disabled Windows sandbox",
AskForApproval::Never,
PermissionProfile::read_only(),
None,
)
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.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("cmd.exe /c dir") && output.contains("rejected: blocked by policy"),
"unexpected output: {output}",
);
Ok(())
}
#[tokio::test]
#[cfg_attr(not(windows), ignore = "requires native Windows PowerShell execution")]
async fn powershell_independent_literal_commands_preserve_url_launch_checks() -> Result<()> {
core_test_support::skip_if_remote!(Ok(()), "requires local Windows PowerShell execution");
let shell_path = codex_shell_command::powershell::try_find_powershell_executable_blocking()
.ok_or_else(|| anyhow::anyhow!("Windows PowerShell is unavailable"))?
.to_path_buf();
for (script, expected_output) in [
(
"Write-Output start 'https://example.invalid/diagnostic'",
Some("https://example.invalid/diagnostic"),
),
(
"Start-Process cmd.exe -ArgumentList '/c exit 0' -Wait; Write-Output 'https://example.invalid/diagnostic'",
Some("https://example.invalid/diagnostic"),
),
("Start-Process 'https://example.invalid/diagnostic'", None),
(
"wt.exe cmd.exe /c start https://example.invalid/diagnostic",
None,
),
(
"Write-Output start 'https://example.invalid/diagnostic' | Out-String",
None,
),
(
"$target = 'https://example.invalid/diagnostic'; Start-Process $target",
None,
),
] {
let server = start_mock_server().await;
let user_shell = codex_shell_command::shell_detect::DetectedShell {
shell_type: codex_shell_command::shell_detect::ShellType::PowerShell,
shell_path: shell_path.clone(),
}
.into();
let mut builder = test_codex()
.with_user_shell(user_shell)
.with_config(|config| {
config
.features
.enable(Feature::UnifiedExec)
.expect("test config should allow feature update");
});
let test = builder.build_with_auto_env(&server).await?;
let call_id = "powershell-policy";
let args = json!({ "cmd": script, "login": false, "yield_time_ms": 10_000 });
mount_sse_once(
&server,
sse(vec![
ev_response_created("powershell-command"),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("powershell-command"),
]),
)
.await;
let results_mock = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("done", "done"),
ev_completed("powershell-result"),
]),
)
.await;
test.submit_turn("run the PowerShell command").await?;
let output = results_mock
.single_request()
.function_call_output_text(call_id)
.expect("shell call should produce a tool response");
match expected_output {
Some(expected_output) => {
assert!(
output.contains("Process exited with code 0"),
"{script}: {output}"
);
assert!(output.contains(expected_output), "{script}: {output}");
}
None => assert!(
output.contains("rejected: blocked by policy"),
"{script}: {output}"
),
}
}
Ok(())
}
#[tokio::test]
async fn execpolicy_blocks_shell_invocation() -> Result<()> {
let mut builder = test_codex().with_config(|config| {
let policy_path = config.codex_home.join("rules").join("policy.rules");
fs::create_dir_all(
policy_path
.parent()
.expect("policy directory must have a parent"),
)
.expect("create policy directory");
fs::write(
&policy_path,
r#"prefix_rule(pattern=["echo"], decision="forbidden")"#,
)
.expect("write policy file");
});
let server = start_mock_server().await;
let test = builder.build(&server).await?;
let call_id = "shell-forbidden";
let args = json!({
"cmd": "echo blocked",
"yield_time_ms": 1_000,
});
mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
]),
)
.await;
let results_mock = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
)
.await;
let session_model = test.session_configured.model.clone();
let (sandbox_policy, permission_profile) =
turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path());
test.codex
.start_or_steer_turn(
TurnInputRequest::user_input(vec![UserInput::Text {
text: "run shell command".into(),
text_elements: Vec::new(),
}])
.with_thread_settings(ThreadSettingsOverrides {
environments: Some(local_requests(test.config.cwd.clone())),
approval_policy: Some(AskForApproval::Never),
sandbox_policy: Some(sandbox_policy),
permission_profile,
collaboration_mode: Some(CollaborationMode {
mode: ModeKind::Default,
settings: Settings {
model: session_model,
reasoning_effort: None,
developer_instructions: None,
},
}),
..Default::default()
}),
)
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let output = results_mock
.single_request()
.function_call_output_text(call_id)
.expect("forbidden command should produce a tool response");
assert!(
output.contains("policy forbids commands starting with `echo`"),
"unexpected output: {output}"
);
Ok(())
}
#[tokio::test]
async fn inherited_policy_allows_remembered_commands_and_enforces_managed_denials() -> Result<()> {
skip_if_target_windows!(Ok(()), "uses POSIX touch");
let server = start_mock_server().await;
let test = test_codex()
.with_cloud_config_bundle(CloudConfigBundleFixture::loader_with_enterprise_requirement(
r#"[rules]
prefix_rules = [{ pattern = [{ token = "touch" }, { token = "denied-marker" }], decision = "forbidden" }]"#,
))
.build_with_auto_env(&server)
.await?;
let mut options = test.start_thread_options().await;
options.config.permissions.approval_policy =
Constrained::allow_any(AskForApproval::UnlessTrusted);
let stack = options
.config
.config_layer_stack
.clone()
.with_user_and_project_exec_policy_rules_ignored(
/*ignore_user_and_project_exec_policy_rules*/ true,
);
let managed = stack
.requirements()
.exec_policy
.as_deref()
.expect("managed rule")
.clone();
let mut snapshot = managed.as_ref().clone();
snapshot.add_prefix_rule(&["touch".into(), "allowed-marker".into()], Decision::Allow)?;
options.config.config_layer_stack = stack;
options.set_inherited_exec_policy(snapshot.into(), Some(managed.into()));
let child = test.thread_manager.start_thread(options).await?.thread;
let requests = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("commands"),
ev_exec_command_call("allowed", "touch allowed-marker"),
ev_exec_command_call("denied", "touch denied-marker"),
ev_completed("commands"),
]),
sse_completed("done"),
],
)
.await;
child
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "run both commands".into(),
text_elements: Vec::new(),
}]))
.await?;
let event = wait_for_event(&child, |event| {
matches!(
event,
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
)
})
.await;
assert!(
matches!(event, EventMsg::TurnComplete(_)),
"unexpected approval: {event:?}"
);
let files = test
.fs()
.read_directory(
&test.executor_environment().selection().cwd,
/*sandbox*/ None,
)
.await?;
assert!(files.iter().any(|file| file.file_name == "allowed-marker"));
assert!(!files.iter().any(|file| file.file_name == "denied-marker"));
let denial = requests
.function_call_output_text("denied")
.expect("denied command output");
assert!(
denial.contains("policy forbids commands starting with `touch denied-marker`"),
"{denial}"
);
Ok(())
}
#[tokio::test]
async fn malformed_custom_rules_preserve_managed_forbidden_prefix() -> Result<()> {
skip_if_target_windows!(
Ok(()),
"managed prefix fixture uses POSIX executable semantics"
);
let mut builder = test_codex()
.with_cloud_config_bundle(
CloudConfigBundleFixture::loader_with_enterprise_requirement(
r#"
[rules]
prefix_rules = [
{ pattern = [{ token = "echo" }], decision = "forbidden" },
]
"#,
),
)
.with_config(|config| {
config
.features
.enable(Feature::UnifiedExec)
.expect("test config should allow feature update");
let policy_path = config.codex_home.join("rules").join("broken.rules");
fs::create_dir_all(
policy_path
.parent()
.expect("policy directory must have a parent"),
)
.expect("create policy directory");
fs::write(policy_path, "prefix_rule(").expect("write malformed policy file");
});
let server = start_mock_server().await;
let test = builder.build_with_auto_env(&server).await?;
let call_id = "managed-shell-forbidden";
let args = json!({
"cmd": "echo blocked",
"yield_time_ms": 1_000,
});
mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-managed-1"),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("resp-managed-1"),
]),
)
.await;
let results_mock = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-managed-1", "done"),
ev_completed("resp-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_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 {
environment_hooks: Default::default(),
allow_login_shell: true,
workspace_roots: selection.workspace_roots.clone(),
permission_profile: PermissionProfileSnapshot::legacy(PermissionProfile::Disabled),
shell_environment_policy: Default::default(),
windows_sandbox_level: WindowsSandboxLevel::from_config(&test.config),
windows_sandbox_type: test.config.permissions.windows_sandbox_type,
use_legacy_landlock: test.config.features.use_legacy_landlock(),
exec_policy: Some(RequirementsExecPolicy::new(invalid_policy)),
mcp_policy: None,
network_policy: None,
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 {
environment_hooks: Default::default(),
allow_login_shell: true,
workspace_roots: selection.workspace_roots.clone(),
permission_profile: PermissionProfileSnapshot::legacy(PermissionProfile::Disabled),
shell_environment_policy: Default::default(),
windows_sandbox_level: WindowsSandboxLevel::from_config(&test.config),
windows_sandbox_type: test.config.permissions.windows_sandbox_type,
use_legacy_landlock: test.config.features.use_legacy_landlock(),
exec_policy: Some(RequirementsExecPolicy::new(environment_policy)),
mcp_policy: None,
network_policy: None,
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 {
environment_hooks: Default::default(),
allow_login_shell: true,
workspace_roots: selection.workspace_roots.clone(),
permission_profile: PermissionProfileSnapshot::legacy(
PermissionProfile::Disabled,
),
shell_environment_policy: Default::default(),
windows_sandbox_level: WindowsSandboxLevel::from_config(&test.config),
windows_sandbox_type: test.config.permissions.windows_sandbox_type,
use_legacy_landlock: test.config.features.use_legacy_landlock(),
exec_policy: Some(RequirementsExecPolicy::new(policy)),
mcp_policy: None,
network_policy: None,
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 unified_exec_empty_script_with_collaboration_mode_does_not_panic() -> Result<()> {
let server = start_mock_server().await;
let mut builder = test_codex().with_model("gpt-5.2").with_config(|config| {
config
.features
.enable(Feature::UnifiedExec)
.expect("test config should allow feature update");
config
.features
.enable(Feature::CollaborationModes)
.expect("test config should allow feature update");
});
let test = builder.build(&server).await?;
let call_id = "unified-exec-empty-script-collab";
let args = json!({
"cmd": "",
"yield_time_ms": 1_000,
});
mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-empty-unified-1"),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("resp-empty-unified-1"),
]),
)
.await;
let results_mock = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-empty-unified-1", "done"),
ev_completed("resp-empty-unified-2"),
]),
)
.await;
let collaboration_mode = collaboration_mode_for_model(test.session_configured.model.clone());
submit_user_turn(
&test,
"run empty unified exec command",
AskForApproval::OnRequest,
PermissionProfile::Disabled,
Some(collaboration_mode),
)
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let output_item = results_mock.single_request().function_call_output(call_id);
assert_no_matched_rules_invariant(&output_item);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unified_exec_whitespace_script_with_collaboration_mode_does_not_panic() -> Result<()> {
let server = start_mock_server().await;
let mut builder = test_codex().with_model("gpt-5.2").with_config(|config| {
config
.features
.enable(Feature::UnifiedExec)
.expect("test config should allow feature update");
config
.features
.enable(Feature::CollaborationModes)
.expect("test config should allow feature update");
});
let test = builder.build(&server).await?;
let call_id = "unified-exec-whitespace-script-collab";
let args = json!({
"cmd": " \n \t",
"yield_time_ms": 1_000,
});
mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-whitespace-unified-1"),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("resp-whitespace-unified-1"),
]),
)
.await;
let results_mock = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-whitespace-unified-1", "done"),
ev_completed("resp-whitespace-unified-2"),
]),
)
.await;
let collaboration_mode = collaboration_mode_for_model(test.session_configured.model.clone());
submit_user_turn(
&test,
"run whitespace unified exec command",
AskForApproval::OnRequest,
PermissionProfile::Disabled,
Some(collaboration_mode),
)
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let output_item = results_mock.single_request().function_call_output(call_id);
assert_no_matched_rules_invariant(&output_item);
Ok(())
}