Files
codex/codex-rs/core/tests/suite/approvals.rs
T
Adam Perry @ OpenAI 404cd42aa5 Accept environment requests at thread and turn settings boundaries (#51422)
## What changed

Add `TurnEnvironmentRequests` to carry environment requests together with their fallback working directory. Update thread settings overrides and active turn settings to accept requests, constructing `TurnEnvironmentSelection` values in the receiving thread before validation and runtime use.

Adapt app-server callers, reviewer settings, and existing tests to supply requests or explicitly convert runtime selections back into requests.

GitOrigin-RevId: 8028f804bebe3fde553f57bb450f6dca3970cc4a
2026-10-06 17:11:07 +00:00

5043 lines
182 KiB
Rust

#![allow(clippy::unwrap_used)]
use anyhow::Context;
use anyhow::Result;
use codex_config::types::ApprovalsReviewer;
use codex_core::CodexThread;
use codex_core::TurnInputRequest;
use codex_core::config::Constrained;
use codex_core::config::ThreadStoreConfig;
use codex_core::sandboxing::SandboxPermissions;
use codex_features::Feature;
use codex_protocol::approvals::NetworkApprovalProtocol;
use codex_protocol::approvals::NetworkPolicyAmendment;
use codex_protocol::approvals::NetworkPolicyRuleAction;
use codex_protocol::config_types::CollaborationMode;
#[cfg(unix)]
use codex_protocol::config_types::EnvironmentVariablePattern;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Settings;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ExecApprovalRequestEvent;
use codex_protocol::protocol::ExecPolicyAmendment;
use codex_protocol::protocol::GranularApprovalConfig;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::ThreadSettingsOverrides;
use codex_protocol::user_input::UserInput;
use core_test_support::managed_network_requirements_loader;
use core_test_support::responses::ev_apply_patch_custom_tool_call;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_function_call;
use core_test_support::responses::ev_function_call_with_namespace;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::mount_sse_once_match;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::TestCodex;
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 core_test_support::wait_for_event_with_timeout;
use core_test_support::zsh_fork::build_zsh_fork_test;
use core_test_support::zsh_fork::restrictive_workspace_write_profile;
use core_test_support::zsh_fork::zsh_fork_runtime;
use core_test_support::zsh_fork::zsh_fork_test_builder;
use pretty_assertions::assert_eq;
use regex_lite::Regex;
use serde_json::Value;
use serde_json::json;
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tempfile::TempDir;
use test_case::test_case;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::Request;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
#[derive(Clone, Copy)]
enum TargetPath {
Workspace(&'static str),
OutsideWorkspace(&'static str),
}
impl TargetPath {
fn resolve_for_patch(self, test: &TestCodex) -> (PathBuf, String) {
match self {
TargetPath::Workspace(name) => {
let path = test.cwd.path().join(name);
(path, name.to_string())
}
TargetPath::OutsideWorkspace(name) => {
let path = test.home.path().join(name);
(path.clone(), path.display().to_string())
}
}
}
}
#[derive(Clone)]
enum ActionKind {
WriteFile {
target: TargetPath,
content: &'static str,
},
FetchUrlNoProxy {
endpoint: &'static str,
response_body: &'static str,
},
FetchUrl {
endpoint: &'static str,
response_body: &'static str,
},
RunCommand {
command: &'static str,
},
RunCommandWithPolicy {
command: &'static str,
policy_src: &'static str,
},
RunCommandWithPrefixRule {
command: &'static str,
prefix_rule: &'static [&'static str],
},
RunUnifiedExecCommand {
command: &'static str,
justification: Option<&'static str>,
},
ApplyPatchFreeform {
target: TargetPath,
content: &'static str,
},
ApplyPatchShell {
target: TargetPath,
content: &'static str,
},
}
const DEFAULT_UNIFIED_EXEC_JUSTIFICATION: &str =
"Requires escalated permissions to bypass the sandbox in tests.";
const WORKSPACE_PERMISSION_PROFILE_CONFIG: &str = r#"default_permissions = ":workspace"
"#;
impl ActionKind {
fn policy_src(&self) -> Option<&'static str> {
match self {
ActionKind::RunCommandWithPolicy { policy_src, .. } => Some(*policy_src),
ActionKind::WriteFile { .. }
| ActionKind::FetchUrlNoProxy { .. }
| ActionKind::FetchUrl { .. }
| ActionKind::RunCommand { .. }
| ActionKind::RunCommandWithPrefixRule { .. }
| ActionKind::RunUnifiedExecCommand { .. }
| ActionKind::ApplyPatchFreeform { .. }
| ActionKind::ApplyPatchShell { .. } => None,
}
}
async fn prepare(
&self,
test: &TestCodex,
server: &MockServer,
call_id: &str,
sandbox_permissions: SandboxPermissions,
) -> Result<(Value, Option<String>)> {
match self {
ActionKind::WriteFile { target, content } => {
let (path, _) = target.resolve_for_patch(test);
let _ = fs::remove_file(&path);
let path_str = path.display().to_string();
let script = format!(
"from pathlib import Path; path = Path({path_str:?}); content = {content:?}; path.write_text(content, encoding='utf-8'); print(path.read_text(encoding='utf-8'), end='')",
);
let command = format!("python3 -c {script:?}");
let event = shell_event(
call_id,
&command,
/*timeout_ms*/ 5_000,
sandbox_permissions,
)?;
Ok((event, Some(command)))
}
ActionKind::FetchUrl {
endpoint,
response_body,
} => {
Mock::given(method("GET"))
.and(path(*endpoint))
.respond_with(
ResponseTemplate::new(200).set_body_string(response_body.to_string()),
)
.mount(server)
.await;
let url = format!("{}{}", server.uri(), endpoint);
let escaped_url = url.replace('\'', "\\'");
let script = format!(
"import sys\nimport urllib.request\nurl = '{escaped_url}'\ntry:\n data = urllib.request.urlopen(url, timeout=2).read().decode()\n print('OK:' + data.strip())\nexcept Exception as exc:\n print('ERR:' + exc.__class__.__name__)\n sys.exit(1)",
);
let command = format!("python3 -c \"{script}\"");
let event = shell_event(
call_id,
&command,
/*timeout_ms*/ 5_000,
sandbox_permissions,
)?;
Ok((event, Some(command)))
}
ActionKind::FetchUrlNoProxy {
endpoint,
response_body,
} => {
Mock::given(method("GET"))
.and(path(*endpoint))
.respond_with(
ResponseTemplate::new(200).set_body_string(response_body.to_string()),
)
.mount(server)
.await;
let url = format!("{}{}", server.uri(), endpoint);
let escaped_url = url.replace('\'', "\\'");
let script = format!(
"import sys\nimport urllib.request\nurl = '{escaped_url}'\nopener = urllib.request.build_opener(urllib.request.ProxyHandler({{}}))\ntry:\n data = opener.open(url, timeout=2).read().decode()\n print('OK:' + data.strip())\nexcept Exception as exc:\n print('ERR:' + exc.__class__.__name__)\n sys.exit(1)",
);
let command = format!("python3 -c \"{script}\"");
let event = shell_event(
call_id,
&command,
/*timeout_ms*/ 5_000,
sandbox_permissions,
)?;
Ok((event, Some(command)))
}
ActionKind::RunCommand { command } => {
// Bazel Linux runners can be heavily oversubscribed while this
// matrix runs, so avoid making scheduling latency look like an
// approval behavior failure.
let event = shell_event(
call_id,
command,
/*timeout_ms*/ 30_000,
sandbox_permissions,
)?;
Ok((event, Some(command.to_string())))
}
ActionKind::RunCommandWithPolicy { command, .. } => {
// Bazel Linux runners can be heavily oversubscribed while this
// matrix runs, so avoid making scheduling latency look like an
// approval behavior failure.
let event = shell_event(
call_id,
command,
/*timeout_ms*/ 30_000,
sandbox_permissions,
)?;
Ok((event, Some(command.to_string())))
}
ActionKind::RunCommandWithPrefixRule {
command,
prefix_rule,
} => {
let event = shell_event_with_prefix_rule(
call_id,
command,
/*timeout_ms*/ 30_000,
sandbox_permissions,
Some(prefix_rule.iter().map(|part| (*part).to_string()).collect()),
)?;
Ok((event, Some(command.to_string())))
}
ActionKind::RunUnifiedExecCommand {
command,
justification,
} => {
let event = exec_command_event(
call_id,
command,
Some(1000),
sandbox_permissions,
*justification,
)?;
Ok((event, Some(command.to_string())))
}
ActionKind::ApplyPatchFreeform { target, content } => {
let (path, patch_path) = target.resolve_for_patch(test);
let _ = fs::remove_file(&path);
let patch = build_add_file_patch(&patch_path, content);
Ok((ev_apply_patch_custom_tool_call(call_id, &patch), None))
}
ActionKind::ApplyPatchShell { target, content } => {
let (path, patch_path) = target.resolve_for_patch(test);
let _ = fs::remove_file(&path);
let patch = build_add_file_patch(&patch_path, content);
let command = shell_apply_patch_command(&patch);
// Bazel may need to launch the configured Codex helper binary
// to apply the verified patch, which can exceed the normal
// short command timeout on slower CI runners.
let timeout_ms = 30_000;
let event = shell_event(call_id, &command, timeout_ms, sandbox_permissions)?;
Ok((event, Some(command)))
}
}
}
}
fn build_add_file_patch(patch_path: &str, content: &str) -> String {
format!("*** Begin Patch\n*** Add File: {patch_path}\n+{content}\n*** End Patch\n")
}
fn shell_apply_patch_command(patch: &str) -> String {
let mut script = String::from("apply_patch <<'PATCH'\n");
script.push_str(patch);
if !patch.ends_with('\n') {
script.push('\n');
}
script.push_str("PATCH\n");
script
}
fn shell_event(
call_id: &str,
command: &str,
timeout_ms: u64,
sandbox_permissions: SandboxPermissions,
) -> Result<Value> {
shell_event_with_prefix_rule(
call_id,
command,
timeout_ms,
sandbox_permissions,
/*prefix_rule*/ None,
)
}
fn shell_event_with_prefix_rule(
call_id: &str,
command: &str,
timeout_ms: u64,
sandbox_permissions: SandboxPermissions,
prefix_rule: Option<Vec<String>>,
) -> Result<Value> {
let mut args = json!({
"cmd": command,
"yield_time_ms": timeout_ms,
});
if sandbox_permissions.requests_sandbox_override() {
args["sandbox_permissions"] = json!(sandbox_permissions);
args["justification"] = json!(DEFAULT_UNIFIED_EXEC_JUSTIFICATION);
}
if let Some(prefix_rule) = prefix_rule {
args["prefix_rule"] = json!(prefix_rule);
}
let args_str = serde_json::to_string(&args)?;
Ok(ev_function_call(call_id, "exec_command", &args_str))
}
fn exec_command_event(
call_id: &str,
cmd: &str,
yield_time_ms: Option<u64>,
sandbox_permissions: SandboxPermissions,
justification: Option<&str>,
) -> Result<Value> {
let mut args = json!({
"cmd": cmd.to_string(),
});
if let Some(yield_time_ms) = yield_time_ms {
args["yield_time_ms"] = json!(yield_time_ms);
}
if sandbox_permissions.requests_sandbox_override() {
args["sandbox_permissions"] = json!(sandbox_permissions);
let reason = justification.unwrap_or(DEFAULT_UNIFIED_EXEC_JUSTIFICATION);
args["justification"] = json!(reason);
}
let args_str = serde_json::to_string(&args)?;
Ok(ev_function_call(call_id, "exec_command", &args_str))
}
#[derive(Clone)]
enum Expectation {
FileCreated {
target: TargetPath,
content: &'static str,
},
FileCreatedNoExitCode {
target: TargetPath,
content: &'static str,
},
PatchApplied {
target: TargetPath,
content: &'static str,
},
FileNotCreated {
target: TargetPath,
message_contains: &'static [&'static str],
},
NetworkSuccess {
body_contains: &'static str,
},
NetworkSuccessNoExitCode {
body_contains: &'static str,
},
NetworkFailure {
expect_tag: &'static str,
},
CommandSuccess {
stdout_contains: &'static str,
},
CommandSuccessNoExitCode {
stdout_contains: &'static str,
},
CommandFailure {
output_contains: &'static str,
},
}
impl Expectation {
fn verify(&self, test: &TestCodex, result: &CommandResult) -> Result<()> {
match self {
Expectation::FileCreated { target, content } => {
let (path, _) = target.resolve_for_patch(test);
assert_eq!(
result.exit_code,
Some(0),
"expected successful exit for {path:?}"
);
assert!(
result.stdout.contains(content),
"stdout missing {content:?}: {}",
result.stdout
);
let file_contents = fs::read_to_string(&path)?;
assert!(
file_contents.contains(content),
"file contents missing {content:?}: {file_contents}"
);
let _ = fs::remove_file(path);
}
Expectation::FileCreatedNoExitCode { target, content } => {
let (path, _) = target.resolve_for_patch(test);
assert!(
result.exit_code.is_none() || result.exit_code == Some(0),
"expected no exit code for {path:?}",
);
assert!(
result.stdout.contains(content),
"stdout missing {content:?}: {}",
result.stdout
);
let file_contents = fs::read_to_string(&path)?;
assert!(
file_contents.contains(content),
"file contents missing {content:?}: {file_contents}"
);
let _ = fs::remove_file(path);
}
Expectation::PatchApplied { target, content } => {
let (path, _) = target.resolve_for_patch(test);
match result.exit_code {
Some(0) | None => {
if result.exit_code.is_none() {
assert!(
result.stdout.contains("Success."),
"patch output missing success indicator: {}",
result.stdout
);
}
}
Some(code) => panic!(
"expected successful patch exit for {:?}, got {code} with stdout {}",
path, result.stdout
),
}
let file_contents = fs::read_to_string(&path)?;
assert!(
file_contents.contains(content),
"patched file missing {content:?}: {file_contents}"
);
let _ = fs::remove_file(path);
}
Expectation::FileNotCreated {
target,
message_contains,
} => {
let (path, _) = target.resolve_for_patch(test);
assert_ne!(
result.exit_code,
Some(0),
"expected non-zero exit for {path:?}"
);
for needle in *message_contains {
if needle.contains('|') {
let options: Vec<&str> = needle.split('|').collect();
let matches_any =
options.iter().any(|option| result.stdout.contains(option));
assert!(
matches_any,
"stdout missing one of {options:?}: {}",
result.stdout
);
} else {
assert!(
result.stdout.contains(needle),
"stdout missing {needle:?}: {}",
result.stdout
);
}
}
assert!(
!path.exists(),
"command should not create {path:?}, but file exists"
);
}
Expectation::NetworkSuccess { body_contains } => {
assert_eq!(
result.exit_code,
Some(0),
"expected successful network exit: {}",
result.stdout
);
assert!(
result.stdout.contains("OK:"),
"stdout missing OK prefix: {}",
result.stdout
);
assert!(
result.stdout.contains(body_contains),
"stdout missing body text {body_contains:?}: {}",
result.stdout
);
}
Expectation::NetworkSuccessNoExitCode { body_contains } => {
assert!(
result.exit_code.is_none() || result.exit_code == Some(0),
"expected no exit code for successful network call: {}",
result.stdout
);
assert!(
result.stdout.contains("OK:"),
"stdout missing OK prefix: {}",
result.stdout
);
assert!(
result.stdout.contains(body_contains),
"stdout missing body text {body_contains:?}: {}",
result.stdout
);
}
Expectation::NetworkFailure { expect_tag } => {
assert_ne!(
result.exit_code,
Some(0),
"expected non-zero exit for network failure: {}",
result.stdout
);
assert!(
result.stdout.contains("ERR:"),
"stdout missing ERR prefix: {}",
result.stdout
);
assert!(
result.stdout.contains(expect_tag),
"stdout missing expected tag {expect_tag:?}: {}",
result.stdout
);
}
Expectation::CommandSuccess { stdout_contains } => {
assert_eq!(
result.exit_code,
Some(0),
"expected successful command exit: {}",
result.stdout
);
assert!(
result.stdout.contains(stdout_contains),
"command stdout missing {stdout_contains:?}: {}",
result.stdout
);
}
Expectation::CommandSuccessNoExitCode { stdout_contains } => {
assert!(
result.exit_code.is_none() || result.exit_code == Some(0),
"expected no exit code for command: {}",
result.stdout
);
assert!(
result.stdout.contains(stdout_contains),
"command stdout missing {stdout_contains:?}: {}",
result.stdout
);
}
Expectation::CommandFailure { output_contains } => {
assert_ne!(
result.exit_code,
Some(0),
"expected non-zero exit for command failure: {}",
result.stdout
);
assert!(
result.stdout.contains(output_contains),
"command failure stderr missing {output_contains:?}: {}",
result.stdout
);
}
}
Ok(())
}
}
#[derive(Clone)]
enum ExpectedExecPolicyAmendment {
Prefix(&'static [&'static str]),
FullCommand,
}
#[derive(Clone)]
enum Outcome {
Auto,
ExecApproval {
decision: ReviewDecision,
expected_reason: Option<&'static str>,
},
ExecApprovalWithAmendment {
decision: ReviewDecision,
expected_reason: Option<&'static str>,
expected_execpolicy_amendment: Option<ExpectedExecPolicyAmendment>,
},
PatchApproval {
decision: ReviewDecision,
expected_reason: Option<&'static str>,
},
}
#[derive(Clone)]
struct ScenarioSpec {
name: &'static str,
approval_policy: AskForApproval,
sandbox_policy: SandboxPolicy,
action: ActionKind,
sandbox_permissions: SandboxPermissions,
features: Vec<Feature>,
outcome: Outcome,
expectation: Expectation,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ScenarioGroup {
DangerFullAccess,
ReadOnly,
WorkspaceWrite,
ApplyPatch,
UnifiedExec,
}
#[derive(Debug, Eq, PartialEq)]
struct CommandResult {
exit_code: Option<i64>,
stdout: String,
}
async fn submit_turn(
test: &TestCodex,
prompt: &str,
approval_policy: AskForApproval,
sandbox_policy: SandboxPolicy,
) -> Result<()> {
let session_model = test.session_configured.model.clone();
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),
approvals_reviewer: Some(ApprovalsReviewer::User),
sandbox_policy: Some(sandbox_policy),
collaboration_mode: Some(CollaborationMode {
mode: ModeKind::Default,
settings: Settings {
model: session_model,
reasoning_effort: None,
developer_instructions: None,
},
}),
..Default::default()
}),
)
.await?;
Ok(())
}
async fn submit_turn_preserving_active_permission_profile(
test: &TestCodex,
prompt: &str,
approval_policy: AskForApproval,
) -> Result<()> {
let session_model = test.session_configured.model.clone();
test.codex
.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),
approvals_reviewer: Some(ApprovalsReviewer::User),
collaboration_mode: Some(CollaborationMode {
mode: ModeKind::Default,
settings: Settings {
model: session_model,
reasoning_effort: None,
developer_instructions: None,
},
}),
..Default::default()
}),
)
.await?;
Ok(())
}
fn assert_active_workspace_permission_profile(test: &TestCodex) {
assert_eq!(
test.session_configured
.active_permission_profile
.as_ref()
.map(|profile| profile.id.as_str()),
Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE)
);
}
fn parse_result(item: &Value) -> CommandResult {
let output_str = item
.get("output")
.and_then(Value::as_str)
.expect("shell output payload");
match serde_json::from_str::<Value>(output_str) {
Ok(parsed) => {
let exit_code = parsed["metadata"]["exit_code"].as_i64();
let stdout = parsed["output"].as_str().unwrap_or_default().to_string();
CommandResult { exit_code, stdout }
}
Err(_) => {
let structured = Regex::new(r"(?s)^Exit code:\s*(-?\d+).*?Output:\n(.*)$").unwrap();
let regex =
Regex::new(r"(?s)^.*?Process exited with code (\d+)\n.*?Output:\n(.*)$").unwrap();
// parse freeform output
if let Some(captures) = structured.captures(output_str) {
let exit_code = captures.get(1).unwrap().as_str().parse::<i64>().unwrap();
let output = captures.get(2).unwrap().as_str();
CommandResult {
exit_code: Some(exit_code),
stdout: output.to_string(),
}
} else if let Some(captures) = regex.captures(output_str) {
let exit_code = captures.get(1).unwrap().as_str().parse::<i64>().unwrap();
let output = captures.get(2).unwrap().as_str();
CommandResult {
exit_code: Some(exit_code),
stdout: output.to_string(),
}
} else {
CommandResult {
exit_code: None,
stdout: output_str.to_string(),
}
}
}
}
}
async fn expect_exec_approval(
test: &TestCodex,
expected_command: &str,
) -> ExecApprovalRequestEvent {
let event = wait_for_event(&test.codex, |event| {
matches!(
event,
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
)
})
.await;
match event {
EventMsg::ExecApprovalRequest(approval) => {
let last_arg = approval
.command
.last()
.map(std::string::String::as_str)
.unwrap_or_default();
assert_eq!(last_arg, expected_command);
approval
}
EventMsg::TurnComplete(_) => panic!("expected approval request before completion"),
other => panic!("unexpected event: {other:?}"),
}
}
async fn expect_patch_approval(
test: &TestCodex,
expected_call_id: &str,
) -> ApplyPatchApprovalRequestEvent {
let event = wait_for_event_with_timeout(
&test.codex,
|event| {
matches!(
event,
EventMsg::ApplyPatchApprovalRequest(_)
| EventMsg::ExecApprovalRequest(_)
| EventMsg::Error(_)
| EventMsg::TurnAborted(_)
| EventMsg::ShutdownComplete
| EventMsg::TurnComplete(_)
)
},
Duration::from_secs(15),
)
.await;
match event {
EventMsg::ApplyPatchApprovalRequest(approval) => {
assert_eq!(approval.call_id, expected_call_id);
approval
}
EventMsg::TurnComplete(_) => panic!("expected patch approval request before completion"),
other => panic!("unexpected event: {other:?}"),
}
}
async fn wait_for_completion_without_approval(test: &TestCodex) {
let event = wait_for_event(&test.codex, |event| {
matches!(
event,
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
)
})
.await;
match event {
EventMsg::TurnComplete(_) => {}
EventMsg::ExecApprovalRequest(event) => {
panic!("unexpected approval request: {:?}", event.command)
}
other => panic!("unexpected event: {other:?}"),
}
}
async fn wait_for_completion(test: &TestCodex) {
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
}
fn body_contains(req: &Request, text: &str) -> bool {
let is_zstd = req
.headers
.get("content-encoding")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| {
value
.split(',')
.any(|entry| entry.trim().eq_ignore_ascii_case("zstd"))
});
let bytes = if is_zstd {
zstd::stream::decode_all(std::io::Cursor::new(&req.body)).ok()
} else {
Some(req.body.clone())
};
bytes
.and_then(|body| String::from_utf8(body).ok())
.is_some_and(|body| body.contains(text))
}
async fn wait_for_spawned_thread(test: &TestCodex) -> Result<Arc<CodexThread>> {
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
loop {
let ids = test.thread_manager.list_thread_ids().await;
if let Some(thread_id) = ids
.iter()
.find(|id| **id != test.session_configured.thread_id)
{
return test
.thread_manager
.get_thread(*thread_id)
.await
.map_err(anyhow::Error::from);
}
if tokio::time::Instant::now() >= deadline {
anyhow::bail!("timed out waiting for spawned thread");
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
fn scenarios() -> Vec<ScenarioSpec> {
use AskForApproval::*;
let workspace_write = |network_access| SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
vec![
ScenarioSpec {
name: "danger_full_access_on_request_allows_outside_write",
approval_policy: OnRequest,
sandbox_policy: SandboxPolicy::DangerFullAccess,
action: ActionKind::WriteFile {
target: TargetPath::OutsideWorkspace("dfa_on_request.txt"),
content: "danger-on-request",
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
outcome: Outcome::Auto,
expectation: Expectation::FileCreated {
target: TargetPath::OutsideWorkspace("dfa_on_request.txt"),
content: "danger-on-request",
},
},
ScenarioSpec {
name: "danger_full_access_on_request_allows_outside_write_gpt_5_1_no_exit",
approval_policy: OnRequest,
sandbox_policy: SandboxPolicy::DangerFullAccess,
action: ActionKind::WriteFile {
target: TargetPath::OutsideWorkspace("dfa_on_request_5_1.txt"),
content: "danger-on-request",
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
outcome: Outcome::Auto,
expectation: Expectation::FileCreated {
target: TargetPath::OutsideWorkspace("dfa_on_request_5_1.txt"),
content: "danger-on-request",
},
},
ScenarioSpec {
name: "danger_full_access_on_request_allows_network",
approval_policy: OnRequest,
sandbox_policy: SandboxPolicy::DangerFullAccess,
action: ActionKind::FetchUrlNoProxy {
endpoint: "/dfa/network",
response_body: "danger-network-ok",
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
outcome: Outcome::Auto,
expectation: Expectation::NetworkSuccess {
body_contains: "danger-network-ok",
},
},
ScenarioSpec {
name: "danger_full_access_on_request_allows_network_gpt_5_1_no_exit",
approval_policy: OnRequest,
sandbox_policy: SandboxPolicy::DangerFullAccess,
action: ActionKind::FetchUrlNoProxy {
endpoint: "/dfa/network",
response_body: "danger-network-ok",
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
outcome: Outcome::Auto,
expectation: Expectation::NetworkSuccessNoExitCode {
body_contains: "danger-network-ok",
},
},
ScenarioSpec {
name: "simple_command_unless_trusted_requires_approval",
approval_policy: UnlessTrusted,
sandbox_policy: SandboxPolicy::DangerFullAccess,
action: ActionKind::RunCommand {
command: "echo trusted-unless",
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
outcome: Outcome::ExecApproval {
decision: ReviewDecision::denied("blocked in untrusted project"),
expected_reason: None,
},
expectation: Expectation::CommandFailure {
output_contains: "blocked in untrusted project",
},
},
ScenarioSpec {
name: "simple_command_unless_trusted_requires_approval_gpt_5_1_no_exit",
approval_policy: UnlessTrusted,
sandbox_policy: SandboxPolicy::DangerFullAccess,
action: ActionKind::RunCommand {
command: "echo trusted-unless",
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
outcome: Outcome::ExecApproval {
decision: ReviewDecision::denied("blocked in untrusted project"),
expected_reason: None,
},
expectation: Expectation::CommandFailure {
output_contains: "blocked in untrusted project",
},
},
ScenarioSpec {
name: "cat_redirect_unless_trusted_requires_approval",
approval_policy: UnlessTrusted,
sandbox_policy: workspace_write(false),
action: ActionKind::RunCommand {
command: r#"cat < "hello" > /var/test.txt"#,
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
outcome: Outcome::ExecApproval {
decision: ReviewDecision::denied("blocked by distinctive approval policy"),
expected_reason: None,
},
expectation: Expectation::CommandFailure {
output_contains: "blocked by distinctive approval policy",
},
},
ScenarioSpec {
name: "cat_redirect_on_request_requires_approval",
approval_policy: OnRequest,
sandbox_policy: workspace_write(false),
action: ActionKind::RunCommand {
command: r#"cat < "hello" > /var/test.txt"#,
},
sandbox_permissions: SandboxPermissions::RequireEscalated,
features: vec![],
outcome: Outcome::ExecApproval {
decision: ReviewDecision::denied("rejected by user"),
expected_reason: None,
},
expectation: Expectation::CommandFailure {
output_contains: "rejected by user",
},
},
ScenarioSpec {
name: "simple_command_escalation_on_request_requires_approval",
approval_policy: OnRequest,
sandbox_policy: workspace_write(false),
action: ActionKind::RunCommand {
command: "echo known-safe-escalation",
},
sandbox_permissions: SandboxPermissions::RequireEscalated,
features: vec![],
outcome: Outcome::ExecApprovalWithAmendment {
decision: ReviewDecision::denied("rejected by user"),
expected_reason: None,
expected_execpolicy_amendment: Some(ExpectedExecPolicyAmendment::Prefix(&[
"echo",
"known-safe-escalation",
])),
},
expectation: Expectation::CommandFailure {
output_contains: "rejected by user",
},
},
ScenarioSpec {
name: "simple_command_escalation_granular_sandbox_enabled_requires_approval",
approval_policy: Granular(GranularApprovalConfig {
sandbox_approval: true,
rules: true,
skill_approval: true,
request_permissions: true,
mcp_elicitations: true,
}),
sandbox_policy: workspace_write(false),
action: ActionKind::RunCommand {
command: "echo known-safe-escalation",
},
sandbox_permissions: SandboxPermissions::RequireEscalated,
features: vec![],
outcome: Outcome::ExecApprovalWithAmendment {
decision: ReviewDecision::denied("rejected by user"),
expected_reason: None,
expected_execpolicy_amendment: Some(ExpectedExecPolicyAmendment::Prefix(&[
"echo",
"known-safe-escalation",
])),
},
expectation: Expectation::CommandFailure {
output_contains: "rejected by user",
},
},
ScenarioSpec {
name: "simple_command_escalation_granular_sandbox_disabled_rejects",
approval_policy: Granular(GranularApprovalConfig {
sandbox_approval: false,
rules: true,
skill_approval: true,
request_permissions: true,
mcp_elicitations: true,
}),
sandbox_policy: workspace_write(false),
action: ActionKind::RunCommand {
command: "echo known-safe-escalation-granular-disabled",
},
sandbox_permissions: SandboxPermissions::RequireEscalated,
features: vec![],
outcome: Outcome::Auto,
expectation: Expectation::CommandFailure {
output_contains: "you cannot ask for escalated permissions",
},
},
ScenarioSpec {
name: "cat_heredoc_inner_allow_rule_requires_escalation_approval",
approval_policy: OnRequest,
sandbox_policy: workspace_write(false),
action: ActionKind::RunCommandWithPolicy {
command: "cat <<'EOF'\nhello\nEOF",
policy_src: r#"prefix_rule(pattern=["cat"], decision="allow")"#,
},
sandbox_permissions: SandboxPermissions::RequireEscalated,
features: vec![],
outcome: Outcome::ExecApprovalWithAmendment {
decision: ReviewDecision::denied("rejected by user"),
expected_reason: None,
expected_execpolicy_amendment: Some(ExpectedExecPolicyAmendment::FullCommand),
},
expectation: Expectation::CommandFailure {
output_contains: "rejected by user",
},
},
ScenarioSpec {
name: "cat_heredoc_file_redirect_prefix_rule_requires_escalation_approval",
approval_policy: OnRequest,
sandbox_policy: workspace_write(false),
action: ActionKind::RunCommandWithPrefixRule {
command: r#"cat <<'EOF' > /tmp/out.txt
hello
EOF"#,
prefix_rule: &["cat"],
},
sandbox_permissions: SandboxPermissions::RequireEscalated,
features: vec![],
outcome: Outcome::ExecApproval {
decision: ReviewDecision::denied("rejected by user"),
expected_reason: None,
},
expectation: Expectation::CommandFailure {
output_contains: "rejected by user",
},
},
ScenarioSpec {
name: "cat_heredoc_variable_assignment_policy_requires_escalation_approval",
approval_policy: OnRequest,
sandbox_policy: workspace_write(false),
action: ActionKind::RunCommandWithPolicy {
command: r#"PATH=/tmp/evil:$PATH cat <<'EOF'
hello
EOF"#,
policy_src: r#"prefix_rule(pattern=["cat"], decision="allow")"#,
},
sandbox_permissions: SandboxPermissions::RequireEscalated,
features: vec![],
outcome: Outcome::ExecApproval {
decision: ReviewDecision::denied("rejected by user"),
expected_reason: None,
},
expectation: Expectation::CommandFailure {
output_contains: "rejected by user",
},
},
ScenarioSpec {
name: "python_heredoc_requested_prefix_rule_proposes_full_command",
approval_policy: OnRequest,
sandbox_policy: workspace_write(false),
action: ActionKind::RunCommandWithPrefixRule {
command: r#"python3 <<'PY'
print('hello')
PY"#,
prefix_rule: &["python3"],
},
sandbox_permissions: SandboxPermissions::RequireEscalated,
features: vec![],
outcome: Outcome::ExecApprovalWithAmendment {
decision: ReviewDecision::denied("rejected by user"),
expected_reason: None,
expected_execpolicy_amendment: Some(ExpectedExecPolicyAmendment::FullCommand),
},
expectation: Expectation::CommandFailure {
output_contains: "rejected by user",
},
},
ScenarioSpec {
name: "danger_full_access_unless_trusted_requests_approval",
approval_policy: UnlessTrusted,
sandbox_policy: SandboxPolicy::DangerFullAccess,
action: ActionKind::WriteFile {
target: TargetPath::OutsideWorkspace("dfa_unless_trusted.txt"),
content: "danger-unless-trusted",
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
outcome: Outcome::ExecApproval {
decision: ReviewDecision::Approved,
expected_reason: None,
},
expectation: Expectation::FileCreated {
target: TargetPath::OutsideWorkspace("dfa_unless_trusted.txt"),
content: "danger-unless-trusted",
},
},
ScenarioSpec {
name: "danger_full_access_unless_trusted_requests_approval_gpt_5_1_no_exit",
approval_policy: UnlessTrusted,
sandbox_policy: SandboxPolicy::DangerFullAccess,
action: ActionKind::WriteFile {
target: TargetPath::OutsideWorkspace("dfa_unless_trusted_5_1.txt"),
content: "danger-unless-trusted",
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
outcome: Outcome::ExecApproval {
decision: ReviewDecision::Approved,
expected_reason: None,
},
expectation: Expectation::FileCreatedNoExitCode {
target: TargetPath::OutsideWorkspace("dfa_unless_trusted_5_1.txt"),
content: "danger-unless-trusted",
},
},
ScenarioSpec {
name: "danger_full_access_never_allows_outside_write",
approval_policy: Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
action: ActionKind::WriteFile {
target: TargetPath::OutsideWorkspace("dfa_never.txt"),
content: "danger-never",
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
outcome: Outcome::Auto,
expectation: Expectation::FileCreated {
target: TargetPath::OutsideWorkspace("dfa_never.txt"),
content: "danger-never",
},
},
ScenarioSpec {
name: "danger_full_access_never_allows_outside_write_gpt_5_1_no_exit",
approval_policy: Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
action: ActionKind::WriteFile {
target: TargetPath::OutsideWorkspace("dfa_never_5_1.txt"),
content: "danger-never",
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
outcome: Outcome::Auto,
expectation: Expectation::FileCreatedNoExitCode {
target: TargetPath::OutsideWorkspace("dfa_never_5_1.txt"),
content: "danger-never",
},
},
ScenarioSpec {
name: "read_only_on_request_requires_approval",
approval_policy: OnRequest,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::WriteFile {
target: TargetPath::Workspace("ro_on_request.txt"),
content: "read-only-approval",
},
sandbox_permissions: SandboxPermissions::RequireEscalated,
features: vec![],
outcome: Outcome::ExecApproval {
decision: ReviewDecision::Approved,
expected_reason: None,
},
expectation: Expectation::FileCreated {
target: TargetPath::Workspace("ro_on_request.txt"),
content: "read-only-approval",
},
},
ScenarioSpec {
name: "read_only_on_request_requires_approval_gpt_5_1_no_exit",
approval_policy: OnRequest,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::WriteFile {
target: TargetPath::Workspace("ro_on_request_5_1.txt"),
content: "read-only-approval",
},
sandbox_permissions: SandboxPermissions::RequireEscalated,
features: vec![],
outcome: Outcome::ExecApproval {
decision: ReviewDecision::Approved,
expected_reason: None,
},
expectation: Expectation::FileCreatedNoExitCode {
target: TargetPath::Workspace("ro_on_request_5_1.txt"),
content: "read-only-approval",
},
},
ScenarioSpec {
name: "simple_command_on_request_read_only_runs_without_prompt",
approval_policy: OnRequest,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::RunCommand {
command: "echo trusted-read-only",
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
outcome: Outcome::Auto,
expectation: Expectation::CommandSuccess {
stdout_contains: "trusted-read-only",
},
},
ScenarioSpec {
name: "simple_command_on_request_read_only_runs_without_prompt_gpt_5_1_no_exit",
approval_policy: OnRequest,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::RunCommand {
command: "echo trusted-read-only",
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
outcome: Outcome::Auto,
expectation: Expectation::CommandSuccessNoExitCode {
stdout_contains: "trusted-read-only",
},
},
ScenarioSpec {
name: "read_only_on_request_blocks_network",
approval_policy: OnRequest,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::FetchUrl {
endpoint: "/ro/network-blocked",
response_body: "should-not-see",
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
outcome: Outcome::Auto,
expectation: Expectation::NetworkFailure { expect_tag: "ERR:" },
},
ScenarioSpec {
name: "read_only_on_request_denied_blocks_execution",
approval_policy: OnRequest,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::WriteFile {
target: TargetPath::Workspace("ro_on_request_denied.txt"),
content: "should-not-write",
},
sandbox_permissions: SandboxPermissions::RequireEscalated,
features: vec![],
outcome: Outcome::ExecApproval {
decision: ReviewDecision::denied("rejected by user"),
expected_reason: None,
},
expectation: Expectation::FileNotCreated {
target: TargetPath::Workspace("ro_on_request_denied.txt"),
message_contains: &["rejected by user"],
},
},
ScenarioSpec {
name: "read_only_on_request_network_escalates_when_approved",
approval_policy: OnRequest,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::FetchUrl {
endpoint: "/ro/network-approved",
response_body: "read-only-network-ok",
},
sandbox_permissions: SandboxPermissions::RequireEscalated,
features: vec![],
outcome: Outcome::ExecApproval {
decision: ReviewDecision::Approved,
expected_reason: None,
},
expectation: Expectation::NetworkSuccess {
body_contains: "read-only-network-ok",
},
},
ScenarioSpec {
name: "read_only_on_request_network_escalates_when_approved_gpt_5_1_no_exit",
approval_policy: OnRequest,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::FetchUrl {
endpoint: "/ro/network-approved",
response_body: "read-only-network-ok",
},
sandbox_permissions: SandboxPermissions::RequireEscalated,
features: vec![],
outcome: Outcome::ExecApproval {
decision: ReviewDecision::Approved,
expected_reason: None,
},
expectation: Expectation::NetworkSuccessNoExitCode {
body_contains: "read-only-network-ok",
},
},
ScenarioSpec {
name: "apply_patch_exec_command_requires_patch_approval",
approval_policy: UnlessTrusted,
sandbox_policy: SandboxPolicy::DangerFullAccess,
action: ActionKind::ApplyPatchShell {
target: TargetPath::Workspace("apply_patch_shell.txt"),
content: "shell-apply-patch",
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
outcome: Outcome::PatchApproval {
decision: ReviewDecision::Approved,
expected_reason: None,
},
expectation: Expectation::PatchApplied {
target: TargetPath::Workspace("apply_patch_shell.txt"),
content: "shell-apply-patch",
},
},
ScenarioSpec {
name: "apply_patch_freeform_auto_inside_workspace",
approval_policy: OnRequest,
sandbox_policy: SandboxPolicy::DangerFullAccess,
action: ActionKind::ApplyPatchFreeform {
target: TargetPath::Workspace("apply_patch_freeform.txt"),
content: "freeform-apply-patch",
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
outcome: Outcome::Auto,
expectation: Expectation::PatchApplied {
target: TargetPath::Workspace("apply_patch_freeform.txt"),
content: "freeform-apply-patch",
},
},
ScenarioSpec {
name: "apply_patch_freeform_danger_allows_outside_workspace",
approval_policy: OnRequest,
sandbox_policy: SandboxPolicy::DangerFullAccess,
action: ActionKind::ApplyPatchFreeform {
target: TargetPath::OutsideWorkspace("apply_patch_freeform_danger.txt"),
content: "freeform-patch-danger",
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
outcome: Outcome::Auto,
expectation: Expectation::PatchApplied {
target: TargetPath::OutsideWorkspace("apply_patch_freeform_danger.txt"),
content: "freeform-patch-danger",
},
},
ScenarioSpec {
name: "apply_patch_freeform_outside_requires_patch_approval",
approval_policy: OnRequest,
sandbox_policy: workspace_write(false),
action: ActionKind::ApplyPatchFreeform {
target: TargetPath::OutsideWorkspace("apply_patch_freeform_outside.txt"),
content: "freeform-patch-outside",
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
outcome: Outcome::PatchApproval {
decision: ReviewDecision::Approved,
expected_reason: None,
},
expectation: Expectation::PatchApplied {
target: TargetPath::OutsideWorkspace("apply_patch_freeform_outside.txt"),
content: "freeform-patch-outside",
},
},
ScenarioSpec {
name: "apply_patch_freeform_outside_denied_blocks_patch",
approval_policy: OnRequest,
sandbox_policy: workspace_write(false),
action: ActionKind::ApplyPatchFreeform {
target: TargetPath::OutsideWorkspace("apply_patch_freeform_outside_denied.txt"),
content: "freeform-patch-outside-denied",
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
outcome: Outcome::PatchApproval {
decision: ReviewDecision::denied("rejected by user"),
expected_reason: None,
},
expectation: Expectation::FileNotCreated {
target: TargetPath::OutsideWorkspace("apply_patch_freeform_outside_denied.txt"),
message_contains: &["patch rejected by user"],
},
},
ScenarioSpec {
name: "apply_patch_exec_command_outside_requires_patch_approval",
approval_policy: OnRequest,
sandbox_policy: workspace_write(false),
action: ActionKind::ApplyPatchShell {
target: TargetPath::OutsideWorkspace("apply_patch_shell_outside.txt"),
content: "shell-patch-outside",
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
outcome: Outcome::PatchApproval {
decision: ReviewDecision::Approved,
expected_reason: None,
},
expectation: Expectation::PatchApplied {
target: TargetPath::OutsideWorkspace("apply_patch_shell_outside.txt"),
content: "shell-patch-outside",
},
},
ScenarioSpec {
name: "apply_patch_freeform_unless_trusted_requires_patch_approval",
approval_policy: UnlessTrusted,
sandbox_policy: SandboxPolicy::DangerFullAccess,
action: ActionKind::ApplyPatchFreeform {
target: TargetPath::Workspace("apply_patch_freeform_unless_trusted.txt"),
content: "freeform-patch-unless-trusted",
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
outcome: Outcome::PatchApproval {
decision: ReviewDecision::Approved,
expected_reason: None,
},
expectation: Expectation::PatchApplied {
target: TargetPath::Workspace("apply_patch_freeform_unless_trusted.txt"),
content: "freeform-patch-unless-trusted",
},
},
ScenarioSpec {
name: "apply_patch_freeform_never_rejects_outside_workspace",
approval_policy: Never,
sandbox_policy: workspace_write(false),
action: ActionKind::ApplyPatchFreeform {
target: TargetPath::OutsideWorkspace("apply_patch_freeform_never.txt"),
content: "freeform-patch-never",
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
outcome: Outcome::Auto,
expectation: Expectation::FileNotCreated {
target: TargetPath::OutsideWorkspace("apply_patch_freeform_never.txt"),
message_contains: &[
"patch rejected: writing outside of the project; rejected by user approval settings",
],
},
},
ScenarioSpec {
name: "read_only_never_reports_sandbox_failure",
approval_policy: Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::WriteFile {
target: TargetPath::Workspace("ro_never.txt"),
content: "read-only-never",
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
outcome: Outcome::Auto,
expectation: Expectation::FileNotCreated {
target: TargetPath::Workspace("ro_never.txt"),
message_contains: if cfg!(target_os = "linux") {
&["Permission denied|Read-only file system"]
} else {
&[
"Permission denied|Operation not permitted|operation not permitted|\
Read-only file system",
]
},
},
},
ScenarioSpec {
name: "simple_command_never_runs_without_prompt",
approval_policy: Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::RunCommand {
command: "echo trusted-never",
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
outcome: Outcome::Auto,
expectation: Expectation::CommandSuccess {
stdout_contains: "trusted-never",
},
},
ScenarioSpec {
name: "workspace_write_on_request_allows_workspace_write",
approval_policy: OnRequest,
sandbox_policy: workspace_write(false),
action: ActionKind::WriteFile {
target: TargetPath::Workspace("ww_on_request.txt"),
content: "workspace-on-request",
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
outcome: Outcome::Auto,
expectation: Expectation::FileCreated {
target: TargetPath::Workspace("ww_on_request.txt"),
content: "workspace-on-request",
},
},
ScenarioSpec {
name: "workspace_write_network_disabled_blocks_network",
approval_policy: OnRequest,
sandbox_policy: workspace_write(false),
action: ActionKind::FetchUrl {
endpoint: "/ww/network-blocked",
response_body: "workspace-network-blocked",
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
outcome: Outcome::Auto,
expectation: Expectation::NetworkFailure { expect_tag: "ERR:" },
},
ScenarioSpec {
name: "workspace_write_on_request_requires_approval_outside_workspace",
approval_policy: OnRequest,
sandbox_policy: workspace_write(false),
action: ActionKind::WriteFile {
target: TargetPath::OutsideWorkspace("ww_on_request_outside.txt"),
content: "workspace-on-request-outside",
},
sandbox_permissions: SandboxPermissions::RequireEscalated,
features: vec![],
outcome: Outcome::ExecApproval {
decision: ReviewDecision::Approved,
expected_reason: None,
},
expectation: Expectation::FileCreated {
target: TargetPath::OutsideWorkspace("ww_on_request_outside.txt"),
content: "workspace-on-request-outside",
},
},
ScenarioSpec {
name: "workspace_write_network_enabled_allows_network",
approval_policy: OnRequest,
sandbox_policy: workspace_write(true),
action: ActionKind::FetchUrl {
endpoint: "/ww/network-ok",
response_body: "workspace-network-ok",
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
outcome: Outcome::Auto,
expectation: Expectation::NetworkSuccess {
body_contains: "workspace-network-ok",
},
},
ScenarioSpec {
name: "workspace_write_never_blocks_outside_workspace",
approval_policy: Never,
sandbox_policy: workspace_write(false),
action: ActionKind::WriteFile {
target: TargetPath::OutsideWorkspace("ww_never.txt"),
content: "workspace-never",
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
outcome: Outcome::Auto,
expectation: Expectation::FileNotCreated {
target: TargetPath::OutsideWorkspace("ww_never.txt"),
message_contains: if cfg!(target_os = "linux") {
&["Permission denied|Read-only file system"]
} else {
&[
"Permission denied|Operation not permitted|operation not permitted|\
Read-only file system",
]
},
},
},
ScenarioSpec {
name: "unified exec on request no approval for simple command",
approval_policy: OnRequest,
sandbox_policy: SandboxPolicy::DangerFullAccess,
action: ActionKind::RunUnifiedExecCommand {
command: "echo \"hello unified exec\"",
justification: None,
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![Feature::UnifiedExec],
outcome: Outcome::Auto,
expectation: Expectation::CommandSuccess {
stdout_contains: "hello unified exec",
},
},
#[cfg(not(all(target_os = "linux", target_arch = "aarch64")))]
// Linux sandbox arg0 test workaround doesn't work on ARM
ScenarioSpec {
name: "unified exec on request escalated requires approval",
approval_policy: OnRequest,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::RunUnifiedExecCommand {
command: "python3 -c 'print('\"'\"'escalated unified exec'\"'\"')'",
justification: Some(DEFAULT_UNIFIED_EXEC_JUSTIFICATION),
},
sandbox_permissions: SandboxPermissions::RequireEscalated,
features: vec![Feature::UnifiedExec],
outcome: Outcome::ExecApproval {
decision: ReviewDecision::Approved,
expected_reason: Some(DEFAULT_UNIFIED_EXEC_JUSTIFICATION),
},
expectation: Expectation::CommandSuccess {
stdout_contains: "escalated unified exec",
},
},
ScenarioSpec {
name: "unified exec on request requires approval unless trusted",
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: SandboxPolicy::DangerFullAccess,
action: ActionKind::RunUnifiedExecCommand {
command: "git reset --hard",
justification: None,
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![Feature::UnifiedExec],
outcome: Outcome::ExecApproval {
decision: ReviewDecision::denied("rejected by user"),
expected_reason: None,
},
expectation: Expectation::CommandFailure {
output_contains: "rejected by user",
},
},
ScenarioSpec {
name: "heredoc with redirect still requires approval",
approval_policy: AskForApproval::OnRequest,
sandbox_policy: workspace_write(false),
action: ActionKind::RunUnifiedExecCommand {
command: "cat <<'EOF' > /tmp/out.txt \nhello\nEOF",
justification: None,
},
sandbox_permissions: SandboxPermissions::RequireEscalated,
features: vec![Feature::UnifiedExec],
outcome: Outcome::ExecApproval {
decision: ReviewDecision::denied("rejected by user"),
expected_reason: None,
},
expectation: Expectation::CommandFailure {
output_contains: "rejected by user",
},
},
ScenarioSpec {
name: "compound command with one safe command still requires approval",
approval_policy: AskForApproval::OnRequest,
sandbox_policy: workspace_write(false),
action: ActionKind::RunUnifiedExecCommand {
command: "cat ./one.txt && touch ./two.txt",
justification: None,
},
sandbox_permissions: SandboxPermissions::RequireEscalated,
features: vec![Feature::UnifiedExec],
outcome: Outcome::ExecApproval {
decision: ReviewDecision::denied("rejected by user"),
expected_reason: None,
},
expectation: Expectation::CommandFailure {
output_contains: "rejected by user",
},
},
// Deny these commands before execution. The nonexistent find target also
// keeps a regression from deleting anything in the test workspace.
ScenarioSpec {
name: "find_brace_expansion_unless_trusted_requires_approval",
approval_policy: UnlessTrusted,
sandbox_policy: workspace_write(false),
action: ActionKind::RunCommand {
command: "find ./missing-approval-target -{delete,print}",
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
outcome: Outcome::ExecApproval {
decision: ReviewDecision::denied("rejected dynamic shell word"),
expected_reason: None,
},
expectation: Expectation::CommandFailure {
output_contains: "rejected dynamic shell word",
},
},
ScenarioSpec {
name: "rg_brace_expansion_unless_trusted_requires_approval",
approval_policy: UnlessTrusted,
sandbox_policy: workspace_write(false),
action: ActionKind::RunCommand {
command: "rg --pre{=,=sh} pattern missing-approval-payload.sh",
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
outcome: Outcome::ExecApproval {
decision: ReviewDecision::denied("rejected dynamic shell word"),
expected_reason: None,
},
expectation: Expectation::CommandFailure {
output_contains: "rejected dynamic shell word",
},
},
ScenarioSpec {
name: "brace_expansion_literal_allow_rule_requires_approval",
approval_policy: UnlessTrusted,
sandbox_policy: workspace_write(false),
action: ActionKind::RunCommandWithPolicy {
command: "find ./missing-approval-target -{delete,print}",
policy_src: r#"prefix_rule(pattern=["find", "./missing-approval-target", "-{delete,print}"], decision="allow")"#,
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
outcome: Outcome::ExecApproval {
decision: ReviewDecision::denied("rejected dynamic shell word"),
expected_reason: None,
},
expectation: Expectation::CommandFailure {
output_contains: "rejected dynamic shell word",
},
},
ScenarioSpec {
name: "rg_brace_expansion_literal_allow_rule_requires_approval",
approval_policy: UnlessTrusted,
sandbox_policy: workspace_write(false),
action: ActionKind::RunCommandWithPolicy {
command: "rg --pre{=,=sh} pattern missing-approval-payload.sh",
policy_src: r#"prefix_rule(pattern=["rg", "--pre{=,=sh}"], decision="allow")"#,
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
outcome: Outcome::ExecApproval {
decision: ReviewDecision::denied("rejected dynamic shell word"),
expected_reason: None,
},
expectation: Expectation::CommandFailure {
output_contains: "rejected dynamic shell word",
},
},
ScenarioSpec {
name: "find_glob_unless_trusted_requires_approval",
approval_policy: UnlessTrusted,
sandbox_policy: workspace_write(false),
action: ActionKind::RunCommand {
command: "find ./missing-approval-target -del*",
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
outcome: Outcome::ExecApproval {
decision: ReviewDecision::denied("rejected dynamic shell word"),
expected_reason: None,
},
expectation: Expectation::CommandFailure {
output_contains: "rejected dynamic shell word",
},
},
ScenarioSpec {
name: "find_escape_unless_trusted_requires_approval",
approval_policy: UnlessTrusted,
sandbox_policy: workspace_write(false),
action: ActionKind::RunCommand {
command: r"find ./missing-approval-target -de\lete",
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
outcome: Outcome::ExecApproval {
decision: ReviewDecision::denied("rejected dynamic shell word"),
expected_reason: None,
},
expectation: Expectation::CommandFailure {
output_contains: "rejected dynamic shell word",
},
},
ScenarioSpec {
name: "find_quoted_escape_unless_trusted_requires_approval",
approval_policy: UnlessTrusted,
sandbox_policy: workspace_write(false),
action: ActionKind::RunCommand {
command: "find ./missing-approval-target \"-de\\\nlete\"",
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
outcome: Outcome::ExecApproval {
decision: ReviewDecision::denied("rejected dynamic shell word"),
expected_reason: None,
},
expectation: Expectation::CommandFailure {
output_contains: "rejected dynamic shell word",
},
},
ScenarioSpec {
name: "heredoc_glob_literal_allow_rule_requires_approval",
approval_policy: UnlessTrusted,
sandbox_policy: workspace_write(false),
action: ActionKind::RunCommandWithPolicy {
command: "find ./missing-approval-target -del* <<'EOF'\nEOF",
policy_src: r#"prefix_rule(pattern=["find", "./missing-approval-target", "-del*"], decision="allow")"#,
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
outcome: Outcome::ExecApproval {
decision: ReviewDecision::denied("rejected dynamic shell word"),
expected_reason: None,
},
expectation: Expectation::CommandFailure {
output_contains: "rejected dynamic shell word",
},
},
ScenarioSpec {
name: "quoted_shell_metacharacters_unless_trusted_require_approval",
approval_policy: UnlessTrusted,
sandbox_policy: workspace_write(false),
action: ActionKind::RunCommand {
command: r#"echo -g"*.py" '-{delete,print}'"#,
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
outcome: Outcome::ExecApproval {
decision: ReviewDecision::denied("blocked in untrusted project"),
expected_reason: None,
},
expectation: Expectation::CommandFailure {
output_contains: "blocked in untrusted project",
},
},
]
}
#[test_case(ScenarioGroup::DangerFullAccess ; "danger_full_access")]
#[test_case(ScenarioGroup::ReadOnly ; "read_only")]
#[test_case(ScenarioGroup::WorkspaceWrite ; "workspace_write")]
#[test_case(ScenarioGroup::ApplyPatch ; "apply_patch")]
#[test_case(ScenarioGroup::UnifiedExec ; "unified_exec")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn approval_matrix_covers_group(group: ScenarioGroup) -> Result<()> {
run_scenario_group(group).await
}
async fn run_scenario_group(group: ScenarioGroup) -> Result<()> {
skip_if_no_network!(Ok(()));
let scenarios = scenarios()
.into_iter()
.filter(|scenario| scenario_group(scenario) == group)
.collect::<Vec<_>>();
assert!(!scenarios.is_empty(), "expected scenarios for {group:?}");
for scenario in scenarios {
run_scenario(&scenario)
.await
.with_context(|| format!("approval scenario failed: {}", scenario.name))?;
}
Ok(())
}
fn scenario_group(scenario: &ScenarioSpec) -> ScenarioGroup {
match &scenario.action {
ActionKind::ApplyPatchFreeform { .. } | ActionKind::ApplyPatchShell { .. } => {
ScenarioGroup::ApplyPatch
}
ActionKind::RunUnifiedExecCommand { .. } => ScenarioGroup::UnifiedExec,
ActionKind::WriteFile { .. }
| ActionKind::FetchUrlNoProxy { .. }
| ActionKind::FetchUrl { .. }
| ActionKind::RunCommand { .. }
| ActionKind::RunCommandWithPolicy { .. }
| ActionKind::RunCommandWithPrefixRule { .. } => match &scenario.sandbox_policy {
SandboxPolicy::DangerFullAccess => ScenarioGroup::DangerFullAccess,
SandboxPolicy::ReadOnly { .. } => ScenarioGroup::ReadOnly,
SandboxPolicy::WorkspaceWrite { .. } => ScenarioGroup::WorkspaceWrite,
SandboxPolicy::ExternalSandbox { .. } => ScenarioGroup::WorkspaceWrite,
},
}
}
async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> {
eprintln!("running approval scenario: {}", scenario.name);
let server = start_mock_server().await;
let approval_policy = scenario.approval_policy;
let sandbox_policy = scenario.sandbox_policy.clone();
let features = scenario.features.clone();
let policy_src = scenario.action.policy_src();
let thread_store_id = format!("approval-scenario-{}", scenario.name);
let mut builder = test_codex()
.with_model("gpt-5.5")
.with_config(move |config| {
// These scenarios assert tool behavior, not rollout persistence.
config.experimental_thread_store = ThreadStoreConfig::InMemory {
id: thread_store_id,
};
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
config
.set_legacy_sandbox_policy(sandbox_policy.clone())
.expect("set sandbox policy");
for feature in features {
config
.features
.enable(feature)
.expect("test config should allow feature update");
}
});
if let Some(policy_src) = policy_src {
builder = builder.with_pre_build_hook(move |home| {
let rules_dir = home.join("rules");
fs::create_dir_all(&rules_dir).expect("create rules dir");
fs::write(rules_dir.join("default.rules"), policy_src).expect("write policy");
});
}
let test = builder.build(&server).await?;
let call_id = scenario.name;
let (event, expected_command) = scenario
.action
.prepare(&test, &server, call_id, scenario.sandbox_permissions)
.await?;
if let Some(command) = expected_command.as_deref() {
eprintln!("approval scenario {} command: {command}", scenario.name);
}
let _ = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-1"),
event,
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;
submit_turn(
&test,
scenario.name,
scenario.approval_policy,
scenario.sandbox_policy.clone(),
)
.await?;
match &scenario.outcome {
Outcome::Auto => {
wait_for_completion_without_approval(&test).await;
}
Outcome::ExecApproval {
decision,
expected_reason,
} => {
let command = expected_command
.as_deref()
.expect("exec approval requires shell command");
let approval = expect_exec_approval(&test, command).await;
if let Some(expected_reason) = expected_reason {
assert_eq!(
approval.reason.as_deref(),
Some(*expected_reason),
"unexpected approval reason for {}",
scenario.name
);
}
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: None,
decision: decision.clone(),
})
.await?;
wait_for_completion(&test).await;
}
Outcome::ExecApprovalWithAmendment {
decision,
expected_reason,
expected_execpolicy_amendment,
} => {
let command = expected_command
.as_deref()
.expect("exec approval requires shell command");
let approval = expect_exec_approval(&test, command).await;
if let Some(expected_reason) = expected_reason {
assert_eq!(
approval.reason.as_deref(),
Some(*expected_reason),
"unexpected approval reason for {}",
scenario.name
);
}
let expected_execpolicy_amendment =
expected_execpolicy_amendment.as_ref().map(|expected| {
ExecPolicyAmendment::new(match expected {
ExpectedExecPolicyAmendment::Prefix(command) => {
command.iter().map(|part| (*part).to_string()).collect()
}
ExpectedExecPolicyAmendment::FullCommand => approval.command.clone(),
})
});
assert_eq!(
approval.proposed_execpolicy_amendment, expected_execpolicy_amendment,
"unexpected execpolicy amendment for {}",
scenario.name
);
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: None,
decision: decision.clone(),
})
.await?;
wait_for_completion(&test).await;
}
Outcome::PatchApproval {
decision,
expected_reason,
} => {
let approval = expect_patch_approval(&test, call_id).await;
if let Some(expected_reason) = expected_reason {
assert_eq!(
approval.reason.as_deref(),
Some(*expected_reason),
"unexpected patch approval reason for {}",
scenario.name
);
}
test.codex
.submit(Op::PatchApproval {
id: approval.call_id,
decision: decision.clone(),
})
.await?;
wait_for_completion(&test).await;
}
}
let output_request = results_mock.single_request();
let output_item = if matches!(scenario.action, ActionKind::ApplyPatchFreeform { .. }) {
output_request.custom_tool_call_output(call_id)
} else {
output_request.function_call_output(call_id)
};
let result = parse_result(&output_item);
eprintln!(
"approval scenario {} result: exit_code={:?} stdout={:?}",
scenario.name, result.exit_code, result.stdout
);
let verification_result = scenario.expectation.verify(&test, &result);
test.codex.shutdown_and_wait().await?;
verification_result
}
#[tokio::test(flavor = "current_thread")]
#[cfg(unix)]
async fn approving_apply_patch_for_session_skips_future_prompts_for_same_file() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let approval_policy = AskForApproval::UnlessTrusted;
let sandbox_policy = SandboxPolicy::DangerFullAccess;
let sandbox_policy_for_config = sandbox_policy.clone();
let mut builder = test_codex()
.with_model("gpt-5.5")
.with_config(move |config| {
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
config
.set_legacy_sandbox_policy(sandbox_policy_for_config)
.expect("set sandbox policy");
config.approvals_reviewer = ApprovalsReviewer::User;
});
let test = builder.build(&server).await?;
let target = TargetPath::OutsideWorkspace("apply_patch_allow_session.txt");
let (path, patch_path) = target.resolve_for_patch(&test);
let _path_cleanup = tempfile::TempPath::try_from_path(path.clone())?;
let _ = fs::remove_file(&path);
let patch_add = build_add_file_patch(&patch_path, "before");
let patch_update = format!(
"*** Begin Patch\n*** Update File: {patch_path}\n@@\n-before\n+after\n*** End Patch\n"
);
let call_id_1 = "apply_patch_allow_session_1";
let call_id_2 = "apply_patch_allow_session_2";
let _ = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-1"),
ev_apply_patch_custom_tool_call(call_id_1, &patch_add),
ev_completed("resp-1"),
]),
)
.await;
let _ = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
)
.await;
submit_turn(
&test,
"apply_patch allow session",
approval_policy,
sandbox_policy.clone(),
)
.await?;
let approval = expect_patch_approval(&test, call_id_1).await;
test.codex
.submit(Op::PatchApproval {
id: approval.call_id,
decision: ReviewDecision::ApprovedForSession,
})
.await?;
wait_for_completion(&test).await;
assert!(fs::read_to_string(&path)?.contains("before"));
let _ = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-3"),
ev_apply_patch_custom_tool_call(call_id_2, &patch_update),
ev_completed("resp-3"),
]),
)
.await;
let _ = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-2", "done"),
ev_completed("resp-4"),
]),
)
.await;
submit_turn(
&test,
"apply_patch allow session followup",
approval_policy,
sandbox_policy.clone(),
)
.await?;
let event = wait_for_event(&test.codex, |event| {
matches!(
event,
EventMsg::ApplyPatchApprovalRequest(_) | EventMsg::TurnComplete(_)
)
})
.await;
match event {
EventMsg::TurnComplete(_) => {}
EventMsg::ApplyPatchApprovalRequest(event) => {
panic!("unexpected patch approval request: {:?}", event.call_id)
}
other => panic!("unexpected event: {other:?}"),
}
assert!(fs::read_to_string(&path)?.contains("after"));
let _ = fs::remove_file(path);
Ok(())
}
#[tokio::test(flavor = "current_thread")]
#[cfg(unix)]
async fn approving_execpolicy_amendment_does_not_reinject_permissions_instructions() -> Result<()> {
assert_execpolicy_amendment_context(PermissionsInstructionsExpectation::Included).await
}
#[tokio::test(flavor = "current_thread")]
#[cfg(unix)]
async fn approving_execpolicy_amendment_is_reported_when_permissions_are_disabled() -> Result<()> {
assert_execpolicy_amendment_context(PermissionsInstructionsExpectation::Omitted).await
}
#[derive(Clone, Copy)]
enum PermissionsInstructionsExpectation {
Included,
Omitted,
}
async fn assert_execpolicy_amendment_context(
expectation: PermissionsInstructionsExpectation,
) -> Result<()> {
let (include_permissions_instructions, expected_permissions_block_count) = match expectation {
PermissionsInstructionsExpectation::Included => (true, 1),
PermissionsInstructionsExpectation::Omitted => (false, 0),
};
let server = start_mock_server().await;
let approval_policy = AskForApproval::OnRequest;
let sandbox_policy = SandboxPolicy::new_read_only_policy();
let sandbox_policy_for_config = sandbox_policy.clone();
let mut builder = test_codex()
.with_model_info_override("gpt-5.4", |model_info| {
model_info.model_messages = None;
})
.with_config(move |config| {
config.include_permissions_instructions = include_permissions_instructions;
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
config
.features
.disable(Feature::DeferredExecutor)
.expect("test config should allow feature update");
config
.set_legacy_sandbox_policy(sandbox_policy_for_config)
.expect("set sandbox policy");
});
let test = builder.build(&server).await?;
let call_id = "permissions-prefix";
let (event, expected_command) = ActionKind::RunCommand {
command: "touch permissions-prefix.txt",
}
.prepare(
&test,
&server,
call_id,
SandboxPermissions::RequireEscalated,
)
.await?;
let expected_command =
expected_command.expect("execpolicy amendment scenario should produce a shell command");
let expected_execpolicy_amendment = ExecPolicyAmendment::new(vec![
"touch".to_string(),
"permissions-prefix.txt".to_string(),
]);
let _ = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-permissions-prefix-1"),
event,
ev_completed("resp-permissions-prefix-1"),
]),
)
.await;
let follow_up = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-permissions-prefix", "done"),
ev_completed("resp-permissions-prefix-2"),
]),
)
.await;
submit_turn(&test, "permissions-prefix", approval_policy, sandbox_policy).await?;
let approval = expect_exec_approval(&test, expected_command.as_str()).await;
assert_eq!(
approval.proposed_execpolicy_amendment,
Some(expected_execpolicy_amendment.clone())
);
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: None,
decision: ReviewDecision::ApprovedExecpolicyAmendment {
proposed_execpolicy_amendment: expected_execpolicy_amendment,
},
})
.await?;
wait_for_completion(&test).await;
let developer_messages = follow_up.single_request().message_input_texts("developer");
let permissions_block_count = developer_messages
.iter()
.map(|message| message.matches("<permissions instructions>").count())
.sum::<usize>();
assert_eq!(
permissions_block_count, expected_permissions_block_count,
"saving an execpolicy amendment must respect the full permissions rendering mode: \
{developer_messages:#?}"
);
assert!(
developer_messages
.iter()
.any(|message| message.contains("Approved command prefix saved:")
&& message.contains(r#"["touch", "permissions-prefix.txt"]"#)),
"expected developer message documenting saved rule, got: {developer_messages:?}"
);
Ok(())
}
#[tokio::test(flavor = "current_thread")]
#[cfg(unix)]
async fn approving_execpolicy_amendment_persists_policy_and_skips_future_prompts() -> Result<()> {
let server = start_mock_server().await;
let approval_policy = AskForApproval::UnlessTrusted;
let sandbox_policy = SandboxPolicy::new_workspace_write_policy();
let sandbox_policy_for_config = sandbox_policy.clone();
let mut builder = test_codex().with_config(move |config| {
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
config
.set_legacy_sandbox_policy(sandbox_policy_for_config)
.expect("set sandbox policy");
});
let test = builder.build(&server).await?;
let allow_prefix_path = test.cwd.path().join("allow-prefix.txt");
let _ = fs::remove_file(&allow_prefix_path);
let call_id_first = "allow-prefix-first";
let (first_event, expected_command) = ActionKind::RunCommand {
command: "touch allow-prefix.txt",
}
.prepare(
&test,
&server,
call_id_first,
SandboxPermissions::UseDefault,
)
.await?;
let expected_command =
expected_command.expect("execpolicy amendment scenario should produce a shell command");
let expected_execpolicy_amendment =
ExecPolicyAmendment::new(vec!["touch".to_string(), "allow-prefix.txt".to_string()]);
let _ = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-allow-prefix-1"),
first_event,
ev_completed("resp-allow-prefix-1"),
]),
)
.await;
let first_results = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-allow-prefix-1", "done"),
ev_completed("resp-allow-prefix-2"),
]),
)
.await;
submit_turn(
&test,
"allow-prefix-first",
approval_policy,
sandbox_policy.clone(),
)
.await?;
let approval = expect_exec_approval(&test, expected_command.as_str()).await;
assert_eq!(
approval.proposed_execpolicy_amendment,
Some(expected_execpolicy_amendment.clone())
);
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: None,
decision: ReviewDecision::ApprovedExecpolicyAmendment {
proposed_execpolicy_amendment: expected_execpolicy_amendment.clone(),
},
})
.await?;
wait_for_completion(&test).await;
let developer_messages = first_results
.single_request()
.message_input_texts("developer");
assert!(
developer_messages
.iter()
.any(|message| message.contains(r#"["touch", "allow-prefix.txt"]"#)),
"expected developer message documenting saved rule, got: {developer_messages:?}"
);
let policy_path = test.home.path().join("rules").join("default.rules");
let policy_contents = fs::read_to_string(&policy_path)?;
assert!(
policy_contents
.contains(r#"prefix_rule(pattern=["touch", "allow-prefix.txt"], decision="allow")"#),
"unexpected policy contents: {policy_contents}"
);
let first_output = parse_result(
&first_results
.single_request()
.function_call_output(call_id_first),
);
assert_eq!(first_output.exit_code.unwrap_or(0), 0);
assert!(
first_output.stdout.is_empty(),
"unexpected stdout: {}",
first_output.stdout
);
assert_eq!(
fs::read_to_string(&allow_prefix_path)?,
"",
"unexpected file contents after first run"
);
let call_id_second = "allow-prefix-second";
let (second_event, second_command) = ActionKind::RunCommand {
command: "touch allow-prefix.txt",
}
.prepare(
&test,
&server,
call_id_second,
SandboxPermissions::UseDefault,
)
.await?;
assert_eq!(second_command.as_deref(), Some(expected_command.as_str()));
let _ = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-allow-prefix-3"),
second_event,
ev_completed("resp-allow-prefix-3"),
]),
)
.await;
let second_results = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-allow-prefix-2", "done"),
ev_completed("resp-allow-prefix-4"),
]),
)
.await;
submit_turn(
&test,
"allow-prefix-second",
approval_policy,
sandbox_policy.clone(),
)
.await?;
wait_for_completion_without_approval(&test).await;
let second_output = parse_result(
&second_results
.single_request()
.function_call_output(call_id_second),
);
assert_eq!(second_output.exit_code.unwrap_or(0), 0);
assert!(
second_output.stdout.is_empty(),
"unexpected stdout: {}",
second_output.stdout
);
assert_eq!(
fs::read_to_string(&allow_prefix_path)?,
"",
"unexpected file contents after second run"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn spawned_subagent_execpolicy_amendment_propagates_to_parent_session() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let approval_policy = AskForApproval::UnlessTrusted;
let sandbox_policy = SandboxPolicy::new_workspace_write_policy();
let sandbox_policy_for_config = sandbox_policy.clone();
let mut builder = test_codex().with_config(move |config| {
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
config
.set_legacy_sandbox_policy(sandbox_policy_for_config)
.expect("set sandbox policy");
config
.features
.enable(Feature::Collab)
.expect("test config should allow feature update");
});
let test = builder.build(&server).await?;
const PARENT_PROMPT: &str = "spawn a child that repeats a command";
const CHILD_PROMPT: &str = "run the same command twice";
const SPAWN_CALL_ID: &str = "spawn-child-1";
const CHILD_CALL_ID_1: &str = "child-touch-1";
const PARENT_CALL_ID_2: &str = "parent-touch-2";
let child_file = test.cwd.path().join("subagent-allow-prefix.txt");
let _ = fs::remove_file(&child_file);
let spawn_args = serde_json::to_string(&json!({
"message": CHILD_PROMPT,
}))?;
mount_sse_once_match(
&server,
|req: &Request| body_contains(req, PARENT_PROMPT),
sse(vec![
ev_response_created("resp-parent-1"),
ev_function_call_with_namespace(
SPAWN_CALL_ID,
"multi_agent_v1",
"spawn_agent",
&spawn_args,
),
ev_completed("resp-parent-1"),
]),
)
.await;
let child_cmd_args = serde_json::to_string(&json!({
"cmd": "touch subagent-allow-prefix.txt",
"yield_time_ms": 10_000,
"prefix_rule": ["touch", "subagent-allow-prefix.txt"],
}))?;
mount_sse_once_match(
&server,
|req: &Request| body_contains(req, CHILD_PROMPT) && !body_contains(req, SPAWN_CALL_ID),
sse(vec![
ev_response_created("resp-child-1"),
ev_function_call(CHILD_CALL_ID_1, "exec_command", &child_cmd_args),
ev_completed("resp-child-1"),
]),
)
.await;
mount_sse_once_match(
&server,
|req: &Request| body_contains(req, CHILD_CALL_ID_1),
sse(vec![
ev_response_created("resp-child-2"),
ev_assistant_message("msg-child-2", "child done"),
ev_completed("resp-child-2"),
]),
)
.await;
mount_sse_once_match(
&server,
|req: &Request| body_contains(req, SPAWN_CALL_ID),
sse(vec![
ev_response_created("resp-parent-2"),
ev_assistant_message("msg-parent-2", "parent done"),
ev_completed("resp-parent-2"),
]),
)
.await;
let _ = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-parent-3"),
ev_function_call(PARENT_CALL_ID_2, "exec_command", &child_cmd_args),
ev_completed("resp-parent-3"),
]),
)
.await;
let _ = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-parent-4"),
ev_assistant_message("msg-parent-4", "parent rerun done"),
ev_completed("resp-parent-4"),
]),
)
.await;
submit_turn(
&test,
PARENT_PROMPT,
approval_policy,
sandbox_policy.clone(),
)
.await?;
let child = wait_for_spawned_thread(&test).await?;
let approval_event = wait_for_event_with_timeout(
&child,
|event| {
matches!(
event,
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
)
},
Duration::from_secs(2),
)
.await;
let EventMsg::ExecApprovalRequest(approval) = approval_event else {
panic!("expected child approval before completion");
};
let expected_execpolicy_amendment = ExecPolicyAmendment::new(vec![
"touch".to_string(),
"subagent-allow-prefix.txt".to_string(),
]);
assert_eq!(
approval.proposed_execpolicy_amendment,
Some(expected_execpolicy_amendment.clone())
);
child
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: None,
decision: ReviewDecision::ApprovedExecpolicyAmendment {
proposed_execpolicy_amendment: expected_execpolicy_amendment,
},
})
.await?;
let child_event = wait_for_event_with_timeout(
&child,
|event| {
matches!(
event,
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
)
},
Duration::from_secs(2),
)
.await;
match child_event {
EventMsg::TurnComplete(_) => {}
EventMsg::ExecApprovalRequest(ev) => {
panic!("unexpected second child approval request: {:?}", ev.command)
}
other => panic!("unexpected event: {other:?}"),
}
assert!(
child_file.exists(),
"expected subagent command to create file"
);
fs::remove_file(&child_file)?;
assert!(
!child_file.exists(),
"expected child file to be removed before parent rerun"
);
submit_turn(
&test,
"parent reruns child command",
approval_policy,
sandbox_policy,
)
.await?;
wait_for_completion_without_approval(&test).await;
Ok(())
}
#[cfg(unix)]
#[test_case("zsh_fork", false, false; "zsh_fork_unsupported")]
#[test_case("direct", false, false; "explicit_zsh")]
#[test_case("direct", true, false; "non_login_in_login_enabled_session")]
#[test_case("direct", true, true; "login_startup")]
#[test_case("filtered_startup", false, false; "filtered_global_startup")]
#[test_case("startup_override", false, false; "global_startup_overrides")]
#[test_case("additional_permissions", false, false; "filtered_host_additional_permissions")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_startup_credentials_are_brokered(
shell_mode: &'static str,
allow_login_shell: bool,
login: bool,
) -> Result<()> {
use codex_otel::MetricsClient;
use codex_otel::MetricsConfig;
use opentelemetry_sdk::metrics::data::AggregatedMetrics;
use opentelemetry_sdk::metrics::data::HistogramDataPoint;
use opentelemetry_sdk::metrics::data::MetricData;
use opentelemetry_sdk::metrics::data::SumDataPoint;
use std::collections::BTreeMap;
skip_if_no_network!(Ok(()));
const GH_HOST: &str = "github.example.com";
const REAL_GITHUB_TOKEN: &str =
"ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
const CUSTOM_HOST: &str = "api.custom.example";
const REAL_CUSTOM_TOKEN: &str = "custom_abcdefghijklmnopqrstuvwx";
let request_extra_permissions = shell_mode == "additional_permissions";
let approval_policy = if request_extra_permissions {
AskForApproval::OnRequest
} else {
AskForApproval::Never
};
let global_startup = matches!(shell_mode, "filtered_startup" | "startup_override");
let direct = shell_mode == "direct" || global_startup || request_extra_permissions;
let builder = if shell_mode == "zsh_fork" {
let Some(runtime) = zsh_fork_runtime("zsh-fork credential broker environment test")? else {
return Ok(());
};
zsh_fork_test_builder(runtime, AskForApproval::Never)
} else {
let Some(zsh) = codex_core::shell::get_shell(codex_core::shell::ShellType::Zsh) else {
return Ok(());
};
test_codex()
.with_user_shell(zsh)
.with_config(move |config| {
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
config.features.enable(Feature::ShellTool).unwrap();
})
};
let outside_dir = tempfile::tempdir_in(std::env::current_dir()?)?;
let outside_path = outside_dir.path().join("unsandboxed-snapshot-write");
let outside_path_arg = shlex::try_join([outside_path.to_string_lossy().as_ref()])?;
let home = Arc::new(TempDir::new()?);
let startup_dir = home.path().join("startup");
fs::create_dir(&startup_dir)?;
let untrusted_snapshot_condition = r#"[[ "$ZSH_EXECUTION_STRING" == *"command env -0"* ]]"#;
fs::write(
startup_dir.join(".zshenv"),
format!(
"export GH_HOST='{GH_HOST}'\n\
if [[ ! -o login ]]; then export GH_ENTERPRISE_TOKEN='{REAL_GITHUB_TOKEN}'; fi\n\
export CUSTOM_API_KEY='{REAL_CUSTOM_TOKEN}'\n\
export CUSTOM_HOST='{CUSTOM_HOST}'\n\
export APP_SETTING EXCLUDED_SETTING\n\
export AUTH_HEADER=\"Bearer $GH_ENTERPRISE_TOKEN\"\n\
export BROKERED_STARTUP_CWD=\"$PWD\"\n\
if {untrusted_snapshot_condition}; then\n\
print -r -- escaped > {outside_path_arg} 2>/dev/null || true\n\
fi\n\
function setopt() {{\n\
builtin setopt \"$@\"\n\
if {untrusted_snapshot_condition}; then\n\
print -r -- escaped > {outside_path_arg} 2>/dev/null || true\n\
fi\n\
}}\n"
),
)?;
fs::write(
startup_dir.join(".zshrc"),
format!(
"export LOGIN_SNAPSHOT_READY=ready\nexport GH_ENTERPRISE_TOKEN='{REAL_GITHUB_TOKEN}'\n\
export AUTH_HEADER=\"Bearer $GH_ENTERPRISE_TOKEN\"\n"
),
)?;
fs::write(
startup_dir.join(".zprofile"),
"export LOGIN_SHELL_READY=ready\n",
)?;
let startup_shell = if global_startup {
let Some(zsh) = codex_core::shell::get_shell(codex_core::shell::ShellType::Zsh) else {
return Ok(());
};
let zsh_path = zsh.derive_exec_args("", /*use_login_shell*/ false)[0].clone();
let path = startup_dir.join("zsh");
codex_utils_cargo_bin::write_executable(
&path,
&format!(
"#!/bin/sh\nexport GH_ENTERPRISE_TOKEN='{REAL_GITHUB_TOKEN}'\nexport CUSTOM_API_KEY='{REAL_CUSTOM_TOKEN}'\nexport AUTH_HEADER=\"Bearer $GH_ENTERPRISE_TOKEN\"\nexec {} \"$@\"\n",
shlex::try_quote(&zsh_path)?
),
)?;
fs::set_permissions(&path, fs::Permissions::from_mode(/*mode*/ 0o700))?;
Some(path)
} else {
None
};
fs::write(
home.path().join("config.toml"),
format!(
r#"default_permissions = "brokered"
permissions = {{ brokered = {{ extends = ":workspace", network = {{ enabled = true, allow_local_binding = true }} }} }}
[features.network_proxy]
enabled = true
credential_broker = true
[features.network_proxy.credentials.custom]
env = ["CUSTOM_API_KEY"]
patterns = ["custom_[a-z]{{24}}"]
url_prefix_from_env = "CUSTOM_HOST"
[shell_environment_policy.set]
ZDOTDIR = "{}"
"#,
startup_dir.display()
),
)?;
let mut builder = builder
.with_home(home)
.with_cloud_config_bundle(managed_network_requirements_loader());
if direct && !allow_login_shell {
let Some(bash) = codex_core::shell::get_shell(codex_core::shell::ShellType::Bash) else {
return Ok(());
};
builder = builder.with_user_shell(bash);
}
let mut builder = builder.with_config(move |config| {
config.permissions.allow_login_shell = allow_login_shell;
config.permissions.shell_environment_policy.exclude.push(
EnvironmentVariablePattern::new_case_insensitive("EXCLUDED_SETTING"),
);
if request_extra_permissions {
config
.features
.enable(Feature::ExecPermissionApprovals)
.unwrap();
config
.features
.enable(Feature::RequestPermissionsTool)
.unwrap();
config.permissions.shell_environment_policy.exclude.push(
EnvironmentVariablePattern::new_case_insensitive("CUSTOM_HOST"),
);
} else if shell_mode == "filtered_startup" {
config.permissions.shell_environment_policy.exclude.extend([
EnvironmentVariablePattern::new_case_insensitive("GH_ENTERPRISE_TOKEN"),
EnvironmentVariablePattern::new_case_insensitive("CUSTOM_API_KEY"),
]);
} else if shell_mode == "startup_override" {
config.permissions.shell_environment_policy.r#set.extend([
("GH_ENTERPRISE_TOKEN".to_string(), String::new()),
("CUSTOM_API_KEY".to_string(), String::new()),
("AUTH_HEADER".to_string(), "enterprise-header".to_string()),
]);
}
config
.features
.enable(Feature::ShellSnapshot)
.expect("test config should allow ShellSnapshot override");
if direct {
config
.features
.disable(Feature::ShellZshFork)
.expect("test config should allow direct shell execution");
}
});
let server = start_mock_server().await;
let mut test = builder.build(&server).await?;
let metrics = if login {
// Keep the collector local to this thread so parallel tests cannot affect counts.
let metrics = MetricsClient::new(
MetricsConfig::in_memory("test", "test", "1", Default::default()).with_runtime_reader(),
)?;
let mut options = test.start_thread_options().await;
options.thread_extension_init.insert(metrics.clone());
let thread = test.thread_manager.start_thread(options).await?;
test.codex.shutdown_and_wait().await?;
test.codex = thread.thread;
test.session_configured = thread.session_configured;
Some(metrics)
} else {
None
};
let snapshot_dir = test.home.path().join("shell_snapshots");
let workdir = if (allow_login_shell && !login) || shell_mode == "zsh_fork" {
test.cwd.path().to_path_buf()
} else {
let workdir = test.cwd.path().join("brokered-subdirectory");
fs::create_dir(&workdir)?;
workdir
};
let call_id = "zsh-fork-brokered-github-credential";
let command = r#"printf '%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n' "$GH_HOST" "$GH_ENTERPRISE_TOKEN" "$CUSTOM_HOST" "$CUSTOM_API_KEY" "$CODEX_NETWORK_PROXY_CREDENTIAL_BROKER_ACTIVE" "$PWD" "$BROKERED_STARTUP_CWD" "${LOGIN_SNAPSHOT_READY-unset}" "$AUTH_HEADER""#;
let command = format!(
"APP_SETTING=production; EXCLUDED_SETTING=denied; [ \"$(/usr/bin/printenv APP_SETTING)\" = production ] || exit 1; if /usr/bin/printenv EXCLUDED_SETTING >/dev/null; then exit 1; fi; {command}"
);
let command = if request_extra_permissions {
let script = format!("{command}\nprintf escaped 2>/dev/null > {outside_path_arg} || true");
format!("/bin/sh -c {}", shlex::try_quote(&script)?)
} else {
command.to_string()
};
let snapshot_dir_arg = shlex::try_join([snapshot_dir.to_string_lossy().as_ref()])?;
let command = format!("{command}; /bin/cat {snapshot_dir_arg}/*.sh > captured-snapshot");
let mut arguments = if direct {
let Some(zsh) = codex_core::shell::get_shell(codex_core::shell::ShellType::Zsh) else {
return Ok(());
};
json!({
"cmd": command,
"shell": startup_shell.map_or_else(
|| zsh.derive_exec_args("", /*use_login_shell*/ false)[0].clone(),
|path| path.display().to_string(),
),
"workdir": workdir,
"login": login,
"yield_time_ms": 10_000,
})
} else {
json!({
"cmd": command,
"workdir": workdir,
"login": login,
"yield_time_ms": 10_000,
})
};
if request_extra_permissions {
let approved_dir = test.home.path().join("approved-command");
fs::create_dir(&approved_dir)?;
arguments["sandbox_permissions"] = json!(SandboxPermissions::WithAdditionalPermissions);
arguments["additional_permissions"] = json!({"file_system": {"write": [approved_dir]}});
}
let event = ev_function_call(call_id, "exec_command", &serde_json::to_string(&arguments)?);
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-zsh-fork-broker-1"),
event,
ev_completed("resp-zsh-fork-broker-1"),
]),
sse(vec![
ev_assistant_message("msg-zsh-fork-broker", "done"),
ev_completed("resp-zsh-fork-broker-2"),
]),
],
)
.await;
submit_turn_preserving_active_permission_profile(
&test,
"show the brokered GitHub environment",
approval_policy,
)
.await?;
if request_extra_permissions {
let mut approvals = 0;
loop {
let event = wait_for_event(&test.codex, |event| {
matches!(
event,
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
)
})
.await;
let EventMsg::ExecApprovalRequest(approval) = event else {
break;
};
approvals += 1;
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: None,
decision: ReviewDecision::Approved,
})
.await?;
}
assert!(approvals > 0, "additional permissions must be approved");
} else {
wait_for_completion_without_approval(&test).await;
}
let output = responses.requests()[1].function_call_output(call_id);
if shell_mode == "zsh_fork" {
let output = output.to_string();
assert!(output.contains("credential brokerage does not yet support shell_zsh_fork"));
assert!(!workdir.join("captured-snapshot").exists());
assert!(!output.contains(REAL_GITHUB_TOKEN));
return Ok(());
}
let result = parse_result(&output);
assert_eq!(result.exit_code, Some(0), "command failed: {result:?}");
if let Some(metrics) = metrics {
let snapshot = metrics.snapshot()?;
let mut counts = BTreeMap::new();
for metric in snapshot
.scope_metrics()
.flat_map(opentelemetry_sdk::metrics::data::ScopeMetrics::metrics)
{
let count = match (metric.name(), metric.data()) {
("codex.shell_snapshot.command", AggregatedMetrics::U64(MetricData::Sum(sum))) => {
for point in sum.data_points() {
let tags = point
.attributes()
.map(|tag| (tag.key.as_str(), tag.value.to_string()))
.collect::<BTreeMap<_, _>>();
assert_eq!(
["version", "state", "outcome"]
.map(|key| tags.get(key).map(String::as_str)),
[Some("v1"), Some("protected"), Some("used")],
);
}
sum.data_points().map(SumDataPoint::value).sum::<u64>()
}
(
"codex.shell_snapshot.wait_ms",
AggregatedMetrics::F64(MetricData::Histogram(histogram)),
) => histogram.data_points().map(HistogramDataPoint::count).sum(),
_ => continue,
};
counts.insert(metric.name(), count);
}
assert_eq!(
counts,
BTreeMap::from([
("codex.shell_snapshot.command", 1),
("codex.shell_snapshot.wait_ms", 1),
]),
);
}
assert!(!result.stdout.contains(REAL_GITHUB_TOKEN));
assert!(!result.stdout.contains(REAL_CUSTOM_TOKEN));
let values = result.stdout.lines().collect::<Vec<_>>();
assert_eq!(values.len(), 9, "unexpected command output: {result:?}");
assert_eq!(values[0], GH_HOST);
assert_ne!(values[1], REAL_GITHUB_TOKEN);
if global_startup {
assert_eq!(values[1], "");
assert_eq!(values[3], "");
assert_eq!(
values[8],
if shell_mode == "startup_override" {
"enterprise-header"
} else {
""
}
);
} else {
assert!(values[1].starts_with("ghp_"));
assert_eq!(values[1].len(), REAL_GITHUB_TOKEN.len());
assert!(values[3].starts_with("custom_"));
assert_eq!(values[3].len(), REAL_CUSTOM_TOKEN.len());
assert_eq!(values[8], format!("Bearer {}", values[1]));
}
assert_eq!(
values[2],
if request_extra_permissions {
""
} else {
CUSTOM_HOST
}
);
assert_ne!(values[3], REAL_CUSTOM_TOKEN);
assert_eq!(values[4], "1");
assert_eq!(PathBuf::from(values[5]), fs::canonicalize(&workdir)?);
assert_eq!(PathBuf::from(values[6]), fs::canonicalize(&workdir)?);
assert_eq!(values[7], if login { "ready" } else { "unset" });
assert!(
!outside_path.exists(),
"shell snapshot startup wrote outside the command sandbox"
);
let snapshot = fs::read_to_string(workdir.join("captured-snapshot"))?;
assert!(snapshot.contains("# Snapshot file"));
assert!(
!snapshot.contains(REAL_GITHUB_TOKEN),
"shell snapshot persisted a real GitHub credential"
);
assert!(
!snapshot.contains(REAL_CUSTOM_TOKEN),
"shell snapshot persisted a real configured credential"
);
Ok(())
}
#[cfg(unix)]
#[test_case("escalated")]
#[test_case("disabled")]
#[test_case("failed")]
#[test_case("heredoc")]
#[test_case("enabled")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn brokered_shell_snapshot_fallback(mode: &'static str) -> Result<()> {
skip_if_no_network!(Ok(()));
const REAL_GITHUB_TOKEN: &str =
"ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let Some(bash) = codex_core::shell::get_shell(codex_core::shell::ShellType::Bash) else {
return Ok(());
};
let startup_dir = tempfile::tempdir_in(std::env::current_dir()?)?;
let startup_path = startup_dir.path().join("startup.sh");
let observed_path = startup_dir.path().join("startup-credentials");
let observed_path_arg = shlex::try_join([observed_path.to_string_lossy().as_ref()])?;
fs::write(
&startup_path,
if mode == "escalated" {
format!("printf '%s\\n' \"${{GH_TOKEN-}}\" >> {observed_path_arg}\n")
} else if mode == "heredoc" {
"brokered_heredoc() {\ncat <<EOF\n# exports 1\n\
export LEGACY_SETTING=production\nAuthorization: ${GH_TOKEN}\n\
EOF\n}\nexport -f brokered_heredoc\n"
.to_string()
} else {
"shopt -s extglob expand_aliases\nexport CORP_REGION=west\ncorp_auth() { case \"$CORP_REGION\" in @(west|east)) printf '%s\\n' \"$CORP_REGION\" \"$GH_TOKEN\" ;; esac; }\nalias corp_login=corp_auth\n".to_string()
},
)?;
let home = Arc::new(TempDir::new()?);
fs::write(
home.path().join("config.toml"),
format!(
r#"default_permissions = "brokered"
features = {{ network_proxy = {{ enabled = true, credential_broker = true }} }}
permissions = {{ brokered = {{ extends = ":workspace", network = {{ enabled = true, allow_local_binding = true }} }} }}
[shell_environment_policy.set]
BASH_ENV = "{}"
GH_TOKEN = "{REAL_GITHUB_TOKEN}"
"#,
startup_path.display()
),
)?;
if mode == "failed" {
fs::write(home.path().join("shell_snapshots"), "not a directory")?;
}
if mode == "escalated" {
let rules_dir = home.path().join("rules");
fs::create_dir_all(&rules_dir)?;
fs::write(
rules_dir.join("default.rules"),
r#"prefix_rule(pattern=["printenv", "GH_TOKEN"], decision="allow")"#,
)?;
}
let approval_policy = if mode == "escalated" {
AskForApproval::OnRequest
} else {
AskForApproval::Never
};
let server = start_mock_server().await;
let mut builder = test_codex()
.with_home(home)
.with_user_shell(bash)
.with_cloud_config_bundle(managed_network_requirements_loader())
.with_config(move |config| {
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
if mode == "disabled" {
config.features.disable(Feature::ShellSnapshot)
} else {
config.features.enable(Feature::ShellSnapshot)
}
.expect("test config should allow ShellSnapshot override");
config
.features
.disable(Feature::ShellZshFork)
.expect("test config should allow direct shell execution");
});
let test = builder.build(&server).await?;
let call_id = "escalated-brokered-shell-startup";
let command = if mode == "heredoc" {
let snapshot_dir = test.home.path().join("shell_snapshots");
let snapshot_dir = shlex::try_join([snapshot_dir.to_string_lossy().as_ref()])?;
format!(
"cat {snapshot_dir}/*.sh > captured-snapshot && brokered_heredoc && printf '%s\\n' \"$GH_TOKEN\""
)
} else if mode == "enabled" {
"corp_login".to_string()
} else {
"printenv GH_TOKEN".to_string()
};
let event = shell_event(
call_id,
&command,
/*timeout_ms*/ 5_000,
if mode == "escalated" {
SandboxPermissions::RequireEscalated
} else {
SandboxPermissions::UseDefault
},
)?;
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-escalated-broker-startup-1"),
event,
ev_completed("resp-escalated-broker-startup-1"),
]),
sse(vec![
ev_assistant_message("msg-escalated-broker-startup", "done"),
ev_completed("resp-escalated-broker-startup-2"),
]),
],
)
.await;
submit_turn_preserving_active_permission_profile(
&test,
"run the allowlisted command with escalated permissions",
approval_policy,
)
.await?;
wait_for_completion_without_approval(&test).await;
let output = responses.requests()[1].function_call_output(call_id);
if matches!(mode, "disabled" | "failed") {
let output = output.to_string();
assert!(
output.contains("credential brokerage could not create a protected shell snapshot")
);
assert!(!output.contains(REAL_GITHUB_TOKEN));
if mode == "failed" {
fs::remove_file(test.home.path().join("shell_snapshots"))?;
let mut first_snapshot_paths = None;
for call_id in ["brokered-startup-retry", "brokered-startup-reuse"] {
let event = shell_event(
call_id,
"printenv GH_TOKEN",
/*timeout_ms*/ 5_000,
SandboxPermissions::UseDefault,
)?;
let retry = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created(call_id),
event,
ev_completed(call_id),
]),
sse(vec![
ev_response_created("resp-brokered-startup-done"),
ev_assistant_message("msg-brokered-startup-done", "done"),
ev_completed("resp-brokered-startup-done"),
]),
],
)
.await;
submit_turn_preserving_active_permission_profile(
&test,
"run the command after repairing snapshot storage",
approval_policy,
)
.await?;
wait_for_completion_without_approval(&test).await;
let result = parse_result(&retry.requests()[1].function_call_output(call_id));
assert_eq!(
result.exit_code,
Some(0),
"snapshot retry failed: {result:?}"
);
assert!(result.stdout.starts_with("ghp_"));
assert!(!result.stdout.contains(REAL_GITHUB_TOKEN));
let mut snapshot_paths = fs::read_dir(test.home.path().join("shell_snapshots"))?
.flatten()
.map(|entry| entry.path())
.filter(|path| path.extension().is_some_and(|extension| extension == "sh"))
.collect::<Vec<_>>();
snapshot_paths.sort();
assert_eq!(snapshot_paths.len(), 1);
if let Some(first_snapshot_paths) = &first_snapshot_paths {
assert_eq!(&snapshot_paths, first_snapshot_paths);
} else {
first_snapshot_paths = Some(snapshot_paths);
}
}
}
return Ok(());
}
let result = parse_result(&output);
if mode == "heredoc" {
assert_eq!(
result.exit_code,
Some(0),
"heredoc replay failed: {result:?}"
);
assert!(!output.to_string().contains(REAL_GITHUB_TOKEN));
let dummy = result
.stdout
.lines()
.last()
.context("live dummy credential")?;
assert!(dummy.starts_with("ghp_"));
assert_eq!(
result.stdout,
format!(
"# exports 1\nexport LEGACY_SETTING=production\nAuthorization: {dummy}\n{dummy}\n"
)
);
let snapshot = fs::read_to_string(test.cwd.path().join("captured-snapshot"))?;
assert!(!snapshot.contains(REAL_GITHUB_TOKEN));
assert!(snapshot.contains("brokered_heredoc"));
assert!(snapshot.contains("Authorization: ${GH_TOKEN}"));
return Ok(());
}
if mode == "enabled" {
assert_eq!(result.exit_code, Some(0), "command failed: {result:?}");
let values = result.stdout.lines().collect::<Vec<_>>();
assert_eq!(values.len(), 2);
assert_eq!(values[0], "west");
assert!(values[1].starts_with("ghp_"));
assert_ne!(values[1], REAL_GITHUB_TOKEN);
return Ok(());
}
assert_eq!(
result,
CommandResult {
exit_code: Some(0),
stdout: format!("{REAL_GITHUB_TOKEN}\n"),
},
"fail-open command did not receive the ordinary real-credential environment"
);
if mode == "escalated" {
let observations = fs::read_to_string(&observed_path)?;
assert!(!observations.is_empty(), "startup file did not run");
assert!(
!observations.lines().any(|value| value != REAL_GITHUB_TOKEN),
"escalated startup observed a broker dummy"
);
}
Ok(())
}
#[cfg(unix)]
#[test_case(true; "credential_startup")]
#[test_case(false; "ordinary_application_env")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn brokered_posix_startup_preserves_application_env(credential_startup: bool) -> Result<()> {
skip_if_no_network!(Ok(()));
const REAL_TOKEN: &str = "ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let Some(shell) = codex_core::shell::get_shell(codex_core::shell::ShellType::Sh) else {
return Ok(());
};
let home = Arc::new(TempDir::new()?);
let startup = home.path().join("startup.sh");
let credential_export = format!("export GH_TOKEN='{REAL_TOKEN}'\n");
fs::write(
&startup,
format!(
"export CORP_REGION=west\n{}",
if credential_startup {
credential_export.as_str()
} else {
""
}
),
)?;
fs::write(
home.path().join("config.toml"),
format!(
r#"default_permissions = "brokered"
features = {{ network_proxy = {{ enabled = true, credential_broker = true }} }}
permissions = {{ brokered = {{ extends = ":workspace", network = {{ enabled = true, allow_local_binding = true }} }} }}
[shell_environment_policy.set]
ENV = "{}"
"#,
startup.display()
),
)?;
let shell_path = shell.derive_exec_args("", /*use_login_shell*/ false)[0].clone();
let server = start_mock_server().await;
let test = test_codex()
.with_home(home)
.with_user_shell(shell)
.with_cloud_config_bundle(managed_network_requirements_loader())
.with_config(move |config| {
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::Never);
config.permissions.allow_login_shell = true;
config.features.enable(Feature::ShellSnapshot).unwrap();
config.features.disable(Feature::ShellZshFork).unwrap();
if !credential_startup {
config
.permissions
.shell_environment_policy
.r#set
.insert("GH_TOKEN".to_string(), REAL_TOKEN.to_string());
}
})
.build(&server)
.await?;
let script = r#"printf '%s\n%s\n%s\n%s\n' "$GH_TOKEN" "$CODEX_NETWORK_PROXY_CREDENTIAL_BROKER_ACTIVE" "${ENV-unset}" "$CORP_REGION""#;
let command = format!(
"{} 2>/dev/null",
shlex::try_join([shell_path.as_str(), "-i", "-c", script])?
);
let call_id = "brokered-posix-startup";
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("posix-startup-1"),
shell_event(
call_id,
&command,
/*timeout_ms*/ 5_000,
SandboxPermissions::UseDefault,
)?,
ev_completed("posix-startup-1"),
]),
sse(vec![
ev_assistant_message("posix-startup-done", "done"),
ev_completed("posix-startup-2"),
]),
],
)
.await;
submit_turn_preserving_active_permission_profile(
&test,
"show the POSIX startup environment",
AskForApproval::Never,
)
.await?;
wait_for_completion_without_approval(&test).await;
let result = parse_result(&responses.requests()[1].function_call_output(call_id));
assert_eq!(result.exit_code, Some(0), "command failed: {result:?}");
assert!(!result.stdout.contains(REAL_TOKEN));
let values = result.stdout.lines().collect::<Vec<_>>();
assert_eq!(values.len(), 4, "unexpected command output: {result:?}");
assert!(values[0].starts_with("ghp_"));
assert_eq!(
&values[1..],
&[
"1",
if credential_startup {
"unset"
} else {
startup.to_str().unwrap()
},
"west"
]
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[cfg(unix)]
async fn env_zsh_script_spawned_by_python_can_request_escalation_under_zsh_fork() -> Result<()> {
skip_if_no_network!(Ok(()));
let Some(runtime) = zsh_fork_runtime("zsh-fork env zsh nested escalation test")? else {
return Ok(());
};
let approval_policy = AskForApproval::OnRequest;
let permission_profile = restrictive_workspace_write_profile();
let outside_dir = tempfile::tempdir_in(std::env::current_dir()?)?;
let outside_path = outside_dir.path().join("zsh-fork-env-zsh-escalated.txt");
let outside_path_arg = shlex::try_join([outside_path.to_string_lossy().as_ref()])?;
let rules = r#"prefix_rule(pattern=["touch"], decision="prompt")"#.to_string();
let server = start_mock_server().await;
let outside_path_for_hook = outside_path.clone();
let test = build_zsh_fork_test(
&server,
runtime,
approval_policy,
permission_profile.clone(),
move |home| {
let _ = fs::remove_file(&outside_path_for_hook);
let rules_dir = home.join("rules");
fs::create_dir_all(&rules_dir).unwrap();
fs::write(rules_dir.join("default.rules"), &rules).unwrap();
},
)
.await?;
let script_path = test.cwd.path().join("runs-under-env-zsh");
codex_utils_cargo_bin::write_executable(
&script_path,
&format!(
"#!/usr/bin/env zsh\ntouch {outside_path_arg}\nprint -r -- nested-env-zsh-complete\n"
),
)?;
let script_literal = serde_json::to_string(script_path.to_string_lossy().as_ref())?;
let python_script = format!(
"import subprocess; subprocess.run([{script_literal}], check=True, close_fds=False)"
);
let command = shlex::try_join(["python3", "-c", python_script.as_str()])?;
let call_id = "zsh-fork-env-zsh-nested-escalation";
let event = shell_event(
call_id,
&command,
/*timeout_ms*/ 30_000,
SandboxPermissions::UseDefault,
)?;
let _ = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-zsh-fork-env-zsh-1"),
event,
ev_completed("resp-zsh-fork-env-zsh-1"),
]),
)
.await;
let results = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-zsh-fork-env-zsh-1", "done"),
ev_completed("resp-zsh-fork-env-zsh-2"),
]),
)
.await;
let session_model = test.session_configured.model.clone();
let (sandbox_policy, permission_profile) =
turn_permission_fields(permission_profile, test.cwd.path());
test.codex
.start_or_steer_turn(
TurnInputRequest::user_input(vec![UserInput::Text {
text: "run nested env zsh script through python".into(),
text_elements: Vec::new(),
}])
.with_thread_settings(ThreadSettingsOverrides {
environments: Some(local_requests(test.config.cwd.clone())),
approval_policy: Some(approval_policy),
approvals_reviewer: Some(ApprovalsReviewer::User),
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?;
let approval_event = wait_for_event_with_timeout(
&test.codex,
|event| {
matches!(
event,
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
)
},
Duration::from_secs(10),
)
.await;
let EventMsg::ExecApprovalRequest(approval) = approval_event else {
panic!("expected nested zsh script to request approval before completion");
};
assert!(
approval.command.iter().any(|arg| arg.ends_with("/touch"))
&& approval
.command
.iter()
.any(|arg| arg == outside_path.to_string_lossy().as_ref()),
"expected approval for nested touch command, got: {:?}",
approval.command
);
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: None,
decision: ReviewDecision::Approved,
})
.await?;
wait_for_completion(&test).await;
let result = parse_result(&results.single_request().function_call_output(call_id));
assert_eq!(
result.exit_code.unwrap_or(0),
0,
"nested env zsh script should complete successfully: {}",
result.stdout
);
assert!(
result.stdout.contains("nested-env-zsh-complete"),
"nested script did not report completion: {}",
result.stdout
);
assert!(
outside_path.exists(),
"approved nested touch should create the out-of-workspace file"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[cfg(unix)]
async fn matched_prefix_rule_runs_unsandboxed_under_zsh_fork() -> Result<()> {
skip_if_no_network!(Ok(()));
let Some(runtime) = zsh_fork_runtime("zsh-fork prefix rule unsandboxed test")? else {
return Ok(());
};
let approval_policy = AskForApproval::Never;
let permission_profile = restrictive_workspace_write_profile();
let outside_dir = tempfile::tempdir_in(std::env::current_dir()?)?;
let outside_path = outside_dir
.path()
.join("zsh-fork-prefix-rule-unsandboxed.txt");
let command = format!("touch {outside_path:?}");
let rules = r#"prefix_rule(pattern=["touch"], decision="allow")"#.to_string();
let server = start_mock_server().await;
let outside_path_for_hook = outside_path.clone();
let test = build_zsh_fork_test(
&server,
runtime,
approval_policy,
permission_profile.clone(),
move |home| {
let _ = fs::remove_file(&outside_path_for_hook);
let rules_dir = home.join("rules");
fs::create_dir_all(&rules_dir).unwrap();
fs::write(rules_dir.join("default.rules"), &rules).unwrap();
},
)
.await?;
let call_id = "zsh-fork-prefix-rule-unsandboxed";
let event = shell_event(
call_id,
&command,
/*timeout_ms*/ 1_000,
SandboxPermissions::UseDefault,
)?;
let _ = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-zsh-fork-prefix-1"),
event,
ev_completed("resp-zsh-fork-prefix-1"),
]),
)
.await;
let results = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-zsh-fork-prefix-1", "done"),
ev_completed("resp-zsh-fork-prefix-2"),
]),
)
.await;
let session_model = test.session_configured.model.clone();
let (sandbox_policy, permission_profile) =
turn_permission_fields(permission_profile, test.cwd.path());
test.codex
.start_or_steer_turn(
TurnInputRequest::user_input(vec![UserInput::Text {
text: "run allowed touch under zsh fork".into(),
text_elements: Vec::new(),
}])
.with_thread_settings(ThreadSettingsOverrides {
environments: Some(local_requests(test.config.cwd.clone())),
approval_policy: Some(approval_policy),
approvals_reviewer: Some(ApprovalsReviewer::User),
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_completion_without_approval(&test).await;
let result = parse_result(&results.single_request().function_call_output(call_id));
assert_eq!(result.exit_code.unwrap_or(0), 0);
assert!(
outside_path.exists(),
"expected matched prefix_rule to rerun touch unsandboxed; output: {}",
result.stdout
);
Ok(())
}
/// Verifies that an allowlisted script retains the originating named profile
/// when its shell tool call requests escalated, unsandboxed execution.
///
/// Tool owners use this pattern when a trusted wrapper must run outside the
/// current sandbox, but then needs to launch child commands back inside the
/// same sandbox with `codex sandbox -P`. The nested invocation must also pass
/// `--include-managed-config` so it continues to honor enterprise requirements.
/// The test proves both halves of that contract: the wrapper writes outside the
/// `:workspace` sandbox, while its inherited profile name remains `:workspace`.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[cfg(unix)]
async fn allowed_escalated_exec_command_inherits_active_permission_profile() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let home = Arc::new(TempDir::new()?);
fs::write(
home.path().join("config.toml"),
WORKSPACE_PERMISSION_PROFILE_CONFIG,
)?;
let script_dir = tempfile::tempdir_in(std::env::current_dir()?)?;
let script_path = script_dir.path().join("print-permission-profile.sh");
let outside_path = script_dir.path().join("unsandboxed-marker");
fs::write(
&script_path,
format!(
r#"#!/bin/sh
# Print the inherited profile so the test can verify that it reached this script.
printenv CODEX_PERMISSION_PROFILE
touch {outside_path:?}
"#
),
)?;
let rules_dir = home.path().join("rules");
fs::create_dir_all(&rules_dir)?;
let script_pattern = serde_json::to_string(&script_path.to_string_lossy())?;
fs::write(
rules_dir.join("default.rules"),
format!(r#"prefix_rule(pattern=["/bin/sh", {script_pattern}], decision="allow")"#),
)?;
let approval_policy = AskForApproval::OnRequest;
let mut builder = test_codex().with_home(home).with_config(move |config| {
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
});
let test = builder.build(&server).await?;
assert!(!outside_path.starts_with(test.config.cwd.as_path()));
assert_active_workspace_permission_profile(&test);
let call_id = "allowed-escalated-shell-inherits-permission-profile";
let command = format!("/bin/sh {script_path:?}");
let event = shell_event(
call_id,
&command,
/*timeout_ms*/ 5_000,
SandboxPermissions::RequireEscalated,
)?;
let _ = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-escalated-profile-1"),
event,
ev_completed("resp-escalated-profile-1"),
]),
)
.await;
let results = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-escalated-profile-1", "done"),
ev_completed("resp-escalated-profile-2"),
]),
)
.await;
submit_turn_preserving_active_permission_profile(
&test,
"run the allowed script with escalated permissions",
approval_policy,
)
.await?;
wait_for_completion_without_approval(&test).await;
let result = parse_result(&results.single_request().function_call_output(call_id));
assert_eq!(
result,
CommandResult {
exit_code: Some(0),
stdout: format!("{BUILT_IN_PERMISSION_PROFILE_WORKSPACE}\n"),
},
"the unsandboxed script should inherit CODEX_PERMISSION_PROFILE from the shell command"
);
assert!(
outside_path.exists(),
"allowed escalated script should run outside the :workspace sandbox"
);
Ok(())
}
#[cfg(unix)]
#[test_case(Some(ReviewDecision::Approved); "approved")]
#[test_case(Some(ReviewDecision::denied("rejected by user")); "denied")]
#[test_case(None; "allow_rule_skips_approval_without_widening")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn explicit_escalation_with_denied_reads(decision: Option<ReviewDecision>) -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let home = Arc::new(TempDir::new()?);
if decision.is_none() {
let rules = home.path().join("rules");
fs::create_dir(&rules)?;
fs::write(
rules.join("default.rules"),
r#"prefix_rule(pattern=["/bin/sh", "escalation.sh"], decision="allow")"#,
)?;
}
let outside_marker = home.path().join("escalated-marker");
let quoted_marker = shlex::try_join([outside_marker.to_string_lossy().as_ref()])?;
let approval_policy = AskForApproval::OnRequest;
let mut builder = test_codex()
.with_home(home)
.with_config(move |config| {
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
config.features.enable(Feature::UnifiedExec).unwrap();
config.features.disable(Feature::ShellZshFork).unwrap();
let mut file_system =
restrictive_workspace_write_profile().file_system_sandbox_policy();
file_system.entries.push(FileSystemSandboxEntry::new(
config.cwd.join("secret.txt").into(),
FileSystemAccessMode::Deny,
));
config
.permissions
.set_permission_profile(PermissionProfile::from_runtime_permissions(
&file_system,
NetworkSandboxPolicy::Restricted,
))
.unwrap();
})
.with_workspace_setup(move |cwd, _fs| async move {
fs::write(cwd.join("secret.txt"), "secret")?;
fs::write(
cwd.join("escalation.sh"),
format!(
"printf ran > ran || exit 10; (printf outside > {quoted_marker}) 2> errors; \
if cat secret.txt > errors 2>&1; then exit 11; fi; \
grep -q secret.txt errors || exit 12; printf 'protected\\n'"
),
)?;
Ok(())
});
let test = builder.build(&server).await?;
let workspace_marker = test.cwd.path().join("ran");
let command = "/bin/sh escalation.sh";
let call_id = "explicit-deny-read-escalation";
let event = exec_command_event(
call_id,
command,
Some(10_000),
SandboxPermissions::RequireEscalated,
/*justification*/ None,
)?;
mount_sse_once(&server, sse(vec![event, ev_completed("run")])).await;
let response = mount_sse_once(&server, sse(vec![ev_completed("done")])).await;
submit_turn_preserving_active_permission_profile(&test, "run the command", approval_policy)
.await?;
let approved = decision == Some(ReviewDecision::Approved);
let rejected = decision.is_some() && !approved;
if let Some(decision) = decision {
let request = expect_exec_approval(&test, command).await;
assert!(!outside_marker.exists() && !workspace_marker.exists());
test.codex
.submit(Op::ExecApproval {
id: request.effective_approval_id(),
turn_id: None,
decision,
})
.await?;
}
wait_for_completion_without_approval(&test).await;
let result = parse_result(&response.single_request().function_call_output(call_id));
if rejected {
assert!(result.stdout.contains("rejected by user"), "{result:?}");
assert!(!workspace_marker.exists());
} else {
assert_eq!(
(result.exit_code, result.stdout.as_str()),
(Some(0), "protected\n")
);
assert_eq!(fs::read_to_string(workspace_marker)?, "ran");
}
assert_eq!(outside_marker.exists(), approved);
Ok(())
}
/// Verifies that zsh-fork applies an inner script's allow rule even when the
/// model invokes an outer wrapper, and that the escalated script retains the
/// named profile needed to reconstruct the original sandbox remotely without
/// dropping managed enterprise requirements. The script treats the inherited
/// environment value as untrusted and accepts only explicitly allowlisted
/// profile names before passing one to `codex sandbox -P`.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[cfg(unix)]
async fn zsh_fork_inner_allowed_script_inherits_active_permission_profile() -> Result<()> {
skip_if_no_network!(Ok(()));
let Some(runtime) = zsh_fork_runtime("zsh-fork remote sandbox wrapper test")? else {
return Ok(());
};
const HOST: &str = "builder.example.com";
let approval_policy = AskForApproval::OnRequest;
let script_dir = tempfile::tempdir_in(std::env::current_dir()?)?;
let wrapper_path = script_dir.path().join("remote-bash");
let remote_bash_path = script_dir.path().join("remote_bash.py");
let outside_path = script_dir.path().join("remote-bash-unsandboxed-marker");
let outside_path_literal = serde_json::to_string(&outside_path.to_string_lossy())?;
codex_utils_cargo_bin::write_executable(
&remote_bash_path,
&format!(
r#"#!/usr/bin/env python3
import argparse
import os
from pathlib import Path
import re
import shlex
import sys
ALLOWED_HOSTS = ("builder.example.com",)
ALLOWED_PROFILES = (":workspace",)
def parse_args():
parser = argparse.ArgumentParser(
description="Print an ssh command that recreates the current Codex sandbox remotely."
)
parser.add_argument("--host", required=True)
try:
separator = sys.argv.index("--")
except ValueError:
parser.error("the remote command must follow --")
args = parser.parse_args(sys.argv[1:separator])
args.command = sys.argv[separator + 1:]
if not args.command:
parser.error("the remote command must not be empty")
if not re.fullmatch(r"[a-z0-9.-]+", args.host) or args.host not in ALLOWED_HOSTS:
parser.error("host is not allowlisted")
return args
def main():
args = parse_args()
profile_name = os.environ.get("CODEX_PERMISSION_PROFILE")
if not profile_name:
raise SystemExit("CODEX_PERMISSION_PROFILE must not be empty")
if profile_name not in ALLOWED_PROFILES:
raise SystemExit("CODEX_PERMISSION_PROFILE is not allowlisted")
shell_command = shlex.join(args.command)
sandbox_command = shlex.join(
[
"codex",
"sandbox",
"-P",
profile_name,
"--include-managed-config",
"--",
"bash",
"-lc",
shell_command,
]
)
print(shlex.join(["ssh", args.host, sandbox_command]))
# Test-only proof that this inner script was allowed to run unsandboxed.
Path({outside_path_literal}).write_text("unsandboxed", encoding="utf-8")
if __name__ == "__main__":
main()
"#
),
)?;
let remote_bash_exec = shlex::try_join([remote_bash_path.to_string_lossy().as_ref()])?;
codex_utils_cargo_bin::write_executable(
&wrapper_path,
&format!(
r#"#!/usr/bin/env zsh
exec {remote_bash_exec} "$@"
"#
),
)?;
let remote_bash_pattern = serde_json::to_string(&remote_bash_path.to_string_lossy())?;
let rules = format!(r#"prefix_rule(pattern=[{remote_bash_pattern}], decision="allow")"#);
let server = start_mock_server().await;
let mut builder =
zsh_fork_test_builder(runtime, approval_policy).with_pre_build_hook(move |home| {
fs::write(
home.join("config.toml"),
WORKSPACE_PERMISSION_PROFILE_CONFIG,
)
.expect("write config");
let rules_dir = home.join("rules");
fs::create_dir_all(&rules_dir).expect("create rules dir");
fs::write(rules_dir.join("default.rules"), rules).expect("write rules");
});
let test = builder.build(&server).await?;
assert!(!outside_path.starts_with(test.config.cwd.as_path()));
assert_active_workspace_permission_profile(&test);
let command = shlex::try_join([
wrapper_path.to_string_lossy().as_ref(),
"--host",
HOST,
"--",
"printf",
"%s",
"hello world",
])?;
let call_id = "zsh-fork-remote-sandbox-wrapper";
let event = shell_event(
call_id,
&command,
/*timeout_ms*/ 30_000,
SandboxPermissions::UseDefault,
)?;
let _ = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-zsh-fork-remote-wrapper-1"),
event,
ev_completed("resp-zsh-fork-remote-wrapper-1"),
]),
)
.await;
let results = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-zsh-fork-remote-wrapper-1", "done"),
ev_completed("resp-zsh-fork-remote-wrapper-2"),
]),
)
.await;
submit_turn_preserving_active_permission_profile(
&test,
"run the remote sandbox wrapper",
approval_policy,
)
.await?;
wait_for_completion_without_approval(&test).await;
let result = parse_result(&results.single_request().function_call_output(call_id));
assert_eq!(
result.exit_code.unwrap_or(0),
0,
"the inner remote_bash.py script should run successfully: {}",
result.stdout
);
let ssh_argv = shlex::split(result.stdout.trim()).context("parse printed ssh command")?;
assert_eq!(
ssh_argv.len(),
3,
"expected ssh HOST LONG_COMMAND, got: {}",
result.stdout
);
assert_eq!(
ssh_argv[..2],
["ssh", HOST],
"remote_bash.py should target only the allowlisted host"
);
let sandbox_argv = shlex::split(&ssh_argv[2]).context("parse remote sandbox command")?;
assert_eq!(
sandbox_argv.len(),
9,
"expected codex sandbox ... bash -lc CMD"
);
assert_eq!(
sandbox_argv[..8],
[
"codex",
"sandbox",
"-P",
BUILT_IN_PERMISSION_PROFILE_WORKSPACE,
"--include-managed-config",
"--",
"bash",
"-lc",
],
"remote_bash.py should use the allowlisted inherited profile and managed configuration to reconstruct the Codex sandbox"
);
let command_argv = shlex::split(&sandbox_argv[8]).context("parse remote bash command")?;
assert_eq!(
command_argv,
["printf", "%s", "hello world"],
"remote_bash.py should preserve every argument after --"
);
assert!(
outside_path.exists(),
"the inner allowlisted script should run outside the :workspace sandbox"
);
Ok(())
}
#[tokio::test(flavor = "current_thread")]
#[cfg(unix)]
async fn invalid_requested_prefix_rule_falls_back_for_compound_command() -> Result<()> {
let server = start_mock_server().await;
let approval_policy = AskForApproval::OnRequest;
let sandbox_policy = SandboxPolicy::new_read_only_policy();
let sandbox_policy_for_config = sandbox_policy.clone();
let mut builder = test_codex().with_config(move |config| {
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
config
.set_legacy_sandbox_policy(sandbox_policy_for_config)
.expect("set sandbox policy");
});
let test = builder.build(&server).await?;
let call_id = "invalid-prefix-rule";
let command =
"touch /tmp/codex-fallback-rule-test.txt && echo hello > /tmp/codex-fallback-rule-test.txt";
let event = shell_event_with_prefix_rule(
call_id,
command,
/*timeout_ms*/ 1_000,
SandboxPermissions::RequireEscalated,
Some(vec!["touch".to_string()]),
)?;
let _ = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-invalid-prefix-1"),
event,
ev_completed("resp-invalid-prefix-1"),
]),
)
.await;
submit_turn(
&test,
"invalid-prefix-rule",
approval_policy,
sandbox_policy.clone(),
)
.await?;
let approval = expect_exec_approval(&test, command).await;
let amendment = approval
.proposed_execpolicy_amendment
.expect("should have a proposed execpolicy amendment");
assert!(amendment.command.contains(&command.to_string()));
Ok(())
}
#[tokio::test(flavor = "current_thread")]
#[cfg(unix)]
async fn approving_fallback_rule_for_compound_command_works() -> Result<()> {
let server = start_mock_server().await;
let approval_policy = AskForApproval::OnRequest;
let sandbox_policy = SandboxPolicy::new_read_only_policy();
let sandbox_policy_for_config = sandbox_policy.clone();
let mut builder = test_codex().with_config(move |config| {
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
config
.set_legacy_sandbox_policy(sandbox_policy_for_config)
.expect("set sandbox policy");
});
let test = builder.build(&server).await?;
let call_id = "invalid-prefix-rule";
let command =
"touch /tmp/codex-fallback-rule-test.txt && echo hello > /tmp/codex-fallback-rule-test.txt";
let event = shell_event_with_prefix_rule(
call_id,
command,
/*timeout_ms*/ 10_000,
SandboxPermissions::RequireEscalated,
Some(vec!["touch".to_string()]),
)?;
let _ = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-invalid-prefix-1"),
event,
ev_completed("resp-invalid-prefix-1"),
]),
)
.await;
submit_turn(
&test,
"invalid-prefix-rule",
approval_policy,
sandbox_policy.clone(),
)
.await?;
let approval = expect_exec_approval(&test, command).await;
let approval_id = approval.effective_approval_id();
let amendment = approval
.proposed_execpolicy_amendment
.expect("should have a proposed execpolicy amendment");
assert!(amendment.command.contains(&command.to_string()));
test.codex
.submit(Op::ExecApproval {
id: approval_id,
turn_id: None,
decision: ReviewDecision::ApprovedExecpolicyAmendment {
proposed_execpolicy_amendment: amendment.clone(),
},
})
.await?;
wait_for_completion(&test).await;
let call_id = "invalid-prefix-rule-again";
let command =
"touch /tmp/codex-fallback-rule-test.txt && echo hello > /tmp/codex-fallback-rule-test.txt";
let event = shell_event_with_prefix_rule(
call_id,
command,
/*timeout_ms*/ 10_000,
SandboxPermissions::RequireEscalated,
Some(vec!["touch".to_string()]),
)?;
let _ = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-invalid-prefix-1"),
event,
ev_completed("resp-invalid-prefix-1"),
]),
)
.await;
let second_results = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-invalid-prefix-1", "done"),
ev_completed("resp-invalid-prefix-2"),
]),
)
.await;
submit_turn(
&test,
"invalid-prefix-rule",
approval_policy,
sandbox_policy.clone(),
)
.await?;
wait_for_completion_without_approval(&test).await;
let second_output = parse_result(
&second_results
.single_request()
.function_call_output(call_id),
);
assert_eq!(second_output.exit_code.unwrap_or(0), 0);
assert!(
second_output.stdout.is_empty(),
"unexpected stdout: {}",
second_output.stdout
);
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn denying_network_policy_amendment_persists_policy_and_skips_future_network_prompt()
-> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let home = Arc::new(TempDir::new()?);
fs::write(
home.path().join("config.toml"),
r#"default_permissions = "workspace"
[permissions.workspace.filesystem]
":minimal" = "read"
[permissions.workspace.network]
enabled = true
mode = "limited"
allow_local_binding = true
"#,
)?;
let approval_policy = AskForApproval::OnRequest;
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: true,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let sandbox_policy_for_config = sandbox_policy.clone();
let mut builder = test_codex()
.with_home(home)
.with_cloud_config_bundle(managed_network_requirements_loader())
.with_config(move |config| {
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
config
.set_legacy_sandbox_policy(sandbox_policy_for_config)
.expect("set sandbox policy");
});
let test = builder.build(&server).await?;
assert!(
test.config.managed_network_requirements_enabled(),
"expected managed network requirements to be enabled"
);
assert!(
test.config.permissions.network.is_some(),
"expected managed network proxy config to be present"
);
test.session_configured
.network_proxy
.as_ref()
.expect("expected runtime managed network proxy addresses");
let call_id_first = "allow-network-first";
// Use urllib without overriding proxy settings so managed-network sessions
// continue to exercise the env-based proxy routing path under bubblewrap.
let fetch_command = r#"python3 -c "import urllib.request; opener = urllib.request.build_opener(urllib.request.ProxyHandler()); print('OK:' + opener.open('http://codex-network-test.invalid', timeout=30).read().decode(errors='replace'))""#
.to_string();
let first_event = shell_event(
call_id_first,
&fetch_command,
/*timeout_ms*/ 30_000,
SandboxPermissions::UseDefault,
)?;
let _ = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-allow-network-1"),
first_event,
ev_completed("resp-allow-network-1"),
]),
)
.await;
let first_results = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-allow-network-1", "done"),
ev_completed("resp-allow-network-2"),
]),
)
.await;
submit_turn(
&test,
"allow-network-first",
approval_policy,
sandbox_policy.clone(),
)
.await?;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
let approval = loop {
let remaining = deadline
.checked_duration_since(std::time::Instant::now())
.expect("timed out waiting for network approval request");
let event = wait_for_event_with_timeout(
&test.codex,
|event| {
matches!(
event,
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
)
},
remaining,
)
.await;
match event {
EventMsg::ExecApprovalRequest(approval) => {
if approval.command.first().map(std::string::String::as_str)
== Some("network-access")
{
break approval;
}
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: None,
decision: ReviewDecision::Approved,
})
.await?;
}
EventMsg::TurnComplete(_) => {
panic!("expected network approval request before completion");
}
other => panic!("unexpected event: {other:?}"),
}
};
let network_context = approval
.network_approval_context
.clone()
.expect("expected network approval context");
assert_eq!(network_context.protocol, NetworkApprovalProtocol::Http);
let expected_network_amendments = vec![
NetworkPolicyAmendment {
host: network_context.host.clone(),
action: NetworkPolicyRuleAction::Allow,
},
NetworkPolicyAmendment {
host: network_context.host.clone(),
action: NetworkPolicyRuleAction::Deny,
},
];
assert_eq!(
approval.proposed_network_policy_amendments,
Some(expected_network_amendments.clone())
);
let deny_network_amendment = expected_network_amendments
.into_iter()
.find(|amendment| amendment.action == NetworkPolicyRuleAction::Deny)
.expect("expected deny network policy amendment");
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: None,
decision: ReviewDecision::NetworkPolicyAmendment {
network_policy_amendment: deny_network_amendment.clone(),
},
})
.await?;
wait_for_completion(&test).await;
let policy_path = test.home.path().join("rules").join("default.rules");
let policy_contents = fs::read_to_string(&policy_path)?;
let expected_rule = format!(
r#"network_rule(host="{}", protocol="{}", decision="deny", justification="Deny {} access to {}")"#,
deny_network_amendment.host,
match network_context.protocol {
NetworkApprovalProtocol::Http => "http",
NetworkApprovalProtocol::Https => "https_connect",
NetworkApprovalProtocol::Socks5Tcp => "socks5_tcp",
NetworkApprovalProtocol::Socks5Udp => "socks5_udp",
},
match network_context.protocol {
NetworkApprovalProtocol::Http => "http",
NetworkApprovalProtocol::Https => "https_connect",
NetworkApprovalProtocol::Socks5Tcp => "socks5_tcp",
NetworkApprovalProtocol::Socks5Udp => "socks5_udp",
},
deny_network_amendment.host
);
assert!(
policy_contents.contains(&expected_rule),
"unexpected policy contents: {policy_contents}"
);
assert!(first_results.requests().iter().any(|request| {
request.body_contains_text(&format!(
"Denied network rule saved in execpolicy (denylist): {}",
deny_network_amendment.host
))
}));
let first_output = parse_result(
&first_results
.single_request()
.function_call_output(call_id_first),
);
Expectation::CommandFailure {
output_contains: "",
}
.verify(&test, &first_output)?;
let call_id_second = "allow-network-second";
let second_event = shell_event(
call_id_second,
&fetch_command,
/*timeout_ms*/ 30_000,
SandboxPermissions::UseDefault,
)?;
let _ = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-allow-network-3"),
second_event,
ev_completed("resp-allow-network-3"),
]),
)
.await;
let second_results = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-allow-network-2", "done"),
ev_completed("resp-allow-network-4"),
]),
)
.await;
submit_turn(
&test,
"allow-network-second",
approval_policy,
sandbox_policy.clone(),
)
.await?;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
loop {
let remaining = deadline
.checked_duration_since(std::time::Instant::now())
.expect("timed out waiting for second turn completion");
let event = wait_for_event_with_timeout(
&test.codex,
|event| {
matches!(
event,
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
)
},
remaining,
)
.await;
match event {
EventMsg::ExecApprovalRequest(approval) => {
if approval.command.first().map(std::string::String::as_str)
== Some("network-access")
{
panic!(
"unexpected network approval request: {:?}",
approval.command
);
}
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: None,
decision: ReviewDecision::Approved,
})
.await?;
}
EventMsg::TurnComplete(_) => break,
other => panic!("unexpected event: {other:?}"),
}
}
let second_output = parse_result(
&second_results
.single_request()
.function_call_output(call_id_second),
);
Expectation::CommandFailure {
output_contains: "",
}
.verify(&test, &second_output)?;
Ok(())
}
#[cfg(not(target_os = "windows"))]
#[tokio::test(flavor = "current_thread")]
async fn network_approval_retry_keeps_deny_read_sandbox_for_escalated_command() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let home = Arc::new(TempDir::new()?);
fs::write(
home.path().join("config.toml"),
r#"default_permissions = "workspace"
[permissions.workspace.filesystem]
":minimal" = "read"
[permissions.workspace.network]
enabled = true
mode = "limited"
allow_local_binding = true
"#,
)?;
let approval_policy = AskForApproval::OnRequest;
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: true,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let mut builder = test_codex()
.with_home(home)
.with_cloud_config_bundle(managed_network_requirements_loader())
.with_config(move |config| {
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
});
let test = builder.build(&server).await?;
assert!(
test.config.managed_network_requirements_enabled(),
"expected managed network requirements to be enabled"
);
assert!(
test.config.permissions.network.is_some(),
"expected managed network proxy config to be present"
);
test.session_configured
.network_proxy
.as_ref()
.expect("expected runtime managed network proxy addresses");
let mut file_system_sandbox_policy =
FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(
&sandbox_policy,
test.config.cwd.as_path(),
);
file_system_sandbox_policy
.entries
.push(FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: format!("{}/**/*.env", test.config.cwd.as_path().display()),
},
access: FileSystemAccessMode::Deny,
missing_path_behavior: None,
});
assert!(
file_system_sandbox_policy.has_denied_read_restrictions(),
"test must exercise a permission profile with denied reads"
);
let permission_profile = PermissionProfile::from_runtime_permissions(
&file_system_sandbox_policy,
NetworkSandboxPolicy::Restricted,
);
let call_id = "deny-read-network-retry";
let fetch_command = r#"python3 -c "import urllib.request; opener = urllib.request.build_opener(urllib.request.ProxyHandler()); print('OK:' + opener.open('http://codex-network-test.invalid', timeout=30).read().decode(errors='replace'))""#
.to_string();
let event = shell_event(
call_id,
&fetch_command,
/*timeout_ms*/ 30_000,
SandboxPermissions::RequireEscalated,
)?;
let _ = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-deny-read-network-1"),
event,
ev_completed("resp-deny-read-network-1"),
]),
)
.await;
let results = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-deny-read-network-1", "done"),
ev_completed("resp-deny-read-network-2"),
]),
)
.await;
let (turn_sandbox_policy, turn_permission_profile) =
turn_permission_fields(permission_profile, test.config.cwd.as_path());
let session_model = test.session_configured.model.clone();
test.codex
.start_or_steer_turn(
TurnInputRequest::user_input(vec![UserInput::Text {
text: "deny-read network retry".into(),
text_elements: Vec::new(),
}])
.with_thread_settings(ThreadSettingsOverrides {
environments: Some(local_requests(test.config.cwd.clone())),
approval_policy: Some(approval_policy),
approvals_reviewer: Some(ApprovalsReviewer::User),
sandbox_policy: Some(turn_sandbox_policy),
permission_profile: turn_permission_profile,
collaboration_mode: Some(CollaborationMode {
mode: ModeKind::Default,
settings: Settings {
model: session_model,
reasoning_effort: None,
developer_instructions: None,
},
}),
..Default::default()
}),
)
.await?;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
let mut command_approval_count = 0;
let approval = loop {
let remaining = deadline
.checked_duration_since(std::time::Instant::now())
.expect("timed out waiting for network approval request");
let event = wait_for_event_with_timeout(
&test.codex,
|event| {
matches!(
event,
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
)
},
remaining,
)
.await;
match event {
EventMsg::ExecApprovalRequest(approval) => {
if approval.command.first().map(std::string::String::as_str)
== Some("network-access")
{
break approval;
}
command_approval_count += 1;
assert_eq!(
command_approval_count, 1,
"expected only the outer explicit escalation approval"
);
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: None,
decision: ReviewDecision::Approved,
})
.await?;
}
EventMsg::TurnComplete(_) => {
panic!("expected network approval request before completion");
}
other => panic!("unexpected event: {other:?}"),
}
};
assert_eq!(command_approval_count, 1);
let network_context = approval
.network_approval_context
.clone()
.expect("expected network approval context");
assert_eq!(network_context.protocol, NetworkApprovalProtocol::Http);
let allow_network_amendment = approval
.proposed_network_policy_amendments
.clone()
.expect("expected proposed network policy amendments")
.into_iter()
.find(|amendment| amendment.action == NetworkPolicyRuleAction::Allow)
.expect("expected allow network policy amendment");
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: None,
decision: ReviewDecision::NetworkPolicyAmendment {
network_policy_amendment: allow_network_amendment,
},
})
.await?;
wait_for_completion(&test).await;
let output = parse_result(&results.single_request().function_call_output(call_id));
assert!(
output.exit_code.is_some(),
"expected the approved retry to report command output"
);
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn network_approval_flow_survives_danger_full_access_session_start() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let home = Arc::new(TempDir::new()?);
fs::write(
home.path().join("config.toml"),
r#"default_permissions = "workspace"
[permissions.workspace.filesystem]
":minimal" = "read"
[permissions.workspace.network]
enabled = true
mode = "limited"
allow_local_binding = true
"#,
)?;
let approval_policy = AskForApproval::OnRequest;
let turn_sandbox_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: true,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let mut builder = test_codex()
.with_home(home)
.with_cloud_config_bundle(managed_network_requirements_loader())
.with_config(move |config| {
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
let cwd = config.cwd.clone();
config
.permissions
.set_legacy_sandbox_policy(SandboxPolicy::DangerFullAccess, cwd.as_path())
.expect("test setup should allow sandbox policy");
});
let test = builder.build(&server).await?;
assert!(
!test.config.managed_network_requirements_enabled(),
"expected managed network requirements to stay inactive in danger-full-access"
);
assert!(
test.config.permissions.network.is_some(),
"expected managed network proxy config to be present"
);
assert!(
test.session_configured.network_proxy.is_none(),
"expected session configured event to hide managed network proxy in danger-full-access"
);
let call_id = "allow-network-after-yolo";
let fetch_command = r#"python3 -c "import urllib.request; opener = urllib.request.build_opener(urllib.request.ProxyHandler()); print('OK:' + opener.open('http://codex-network-test.invalid', timeout=30).read().decode(errors='replace'))""#
.to_string();
let event = shell_event(
call_id,
&fetch_command,
/*timeout_ms*/ 30_000,
SandboxPermissions::UseDefault,
)?;
let _ = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-network-after-yolo-1"),
event,
ev_completed("resp-network-after-yolo-1"),
]),
)
.await;
let _ = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-network-after-yolo-1", "done"),
ev_completed("resp-network-after-yolo-2"),
]),
)
.await;
submit_turn(
&test,
"allow-network-after-yolo",
approval_policy,
turn_sandbox_policy,
)
.await?;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
let approval = loop {
let remaining = deadline
.checked_duration_since(std::time::Instant::now())
.expect("timed out waiting for network approval request");
let event = wait_for_event_with_timeout(
&test.codex,
|event| {
matches!(
event,
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
)
},
remaining,
)
.await;
match event {
EventMsg::ExecApprovalRequest(approval) => {
if approval.command.first().map(std::string::String::as_str)
== Some("network-access")
{
break approval;
}
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: None,
decision: ReviewDecision::Approved,
})
.await?;
}
EventMsg::TurnComplete(_) => {
panic!("expected network approval request before completion");
}
other => panic!("unexpected event: {other:?}"),
}
};
let network_context = approval
.network_approval_context
.clone()
.expect("expected network approval context");
assert_eq!(network_context.protocol, NetworkApprovalProtocol::Http);
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: None,
decision: ReviewDecision::denied("rejected by user"),
})
.await?;
wait_for_completion(&test).await;
Ok(())
}
// todo(dylan) add ScenarioSpec support for rules
#[tokio::test(flavor = "current_thread")]
#[cfg(unix)]
async fn compound_command_with_one_safe_command_still_requires_approval() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let approval_policy = AskForApproval::UnlessTrusted;
let sandbox_policy = SandboxPolicy::new_workspace_write_policy();
let sandbox_policy_for_config = sandbox_policy.clone();
let mut builder = test_codex().with_config(move |config| {
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
config
.set_legacy_sandbox_policy(sandbox_policy_for_config)
.expect("set sandbox policy");
});
let test = builder.build(&server).await?;
let rules_dir = test.home.path().join("rules");
fs::create_dir_all(&rules_dir)?;
fs::write(
rules_dir.join("default.rules"),
r#"prefix_rule(pattern=["touch", "allow-prefix.txt"], decision="allow")"#,
)?;
let call_id = "heredoc-with-chained-prefix";
let command = "touch ./test.txt && rm ./test.txt";
let (event, expected_command) = ActionKind::RunCommand { command }
.prepare(&test, &server, call_id, SandboxPermissions::UseDefault)
.await?;
let expected_command =
expected_command.expect("compound command should produce a shell command");
let _ = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-heredoc-prefix-1"),
event,
ev_completed("resp-heredoc-prefix-1"),
]),
)
.await;
let _ = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-heredoc-prefix-1", "done"),
ev_completed("resp-heredoc-prefix-2"),
]),
)
.await;
submit_turn(
&test,
"compound command",
approval_policy,
sandbox_policy.clone(),
)
.await?;
let approval = expect_exec_approval(&test, expected_command.as_str()).await;
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: None,
decision: ReviewDecision::denied("rejected by user"),
})
.await?;
wait_for_completion(&test).await;
Ok(())
}