feat(sandbox): enforce proxy-aware network routing in sandbox (#11113)

## Summary
- expand proxy env injection to cover common tool env vars
(`HTTP_PROXY`/`HTTPS_PROXY`/`ALL_PROXY`/`NO_PROXY` families +
tool-specific variants)
- harden macOS Seatbelt network policy generation to route through
inferred loopback proxy endpoints and fail closed when proxy env is
malformed
- thread proxy-aware Linux sandbox flags and add minimal bwrap netns
isolation hook for restricted non-proxy runs
- add/refresh tests for proxy env wiring, Seatbelt policy generation,
and Linux sandbox argument wiring
This commit is contained in:
viyatb-oai
2026-02-09 23:44:21 -08:00
committed by GitHub
Unverified
parent b61ea47e83
commit 3391e5ea86
24 changed files with 1046 additions and 122 deletions
+28 -19
View File
@@ -26,9 +26,10 @@ use crate::protocol::ExecCommandOutputDeltaEvent;
use crate::protocol::ExecOutputStream;
use crate::protocol::SandboxPolicy;
use crate::sandboxing::CommandSpec;
use crate::sandboxing::ExecEnv;
use crate::sandboxing::ExecRequest;
use crate::sandboxing::SandboxManager;
use crate::sandboxing::SandboxPermissions;
use crate::spawn::SpawnChildRequest;
use crate::spawn::StdioPolicy;
use crate::spawn::spawn_child_async;
use crate::text_encoding::bytes_to_string_smart;
@@ -157,9 +158,18 @@ pub async fn process_exec_tool_call(
stdout_stream: Option<StdoutStream>,
) -> Result<ExecToolCallOutput> {
let windows_sandbox_level = params.windows_sandbox_level;
let enforce_managed_network = params.network.is_some();
let sandbox_type = match &sandbox_policy {
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {
SandboxType::None
if enforce_managed_network {
get_platform_sandbox(
windows_sandbox_level
!= codex_protocol::config_types::WindowsSandboxLevel::Disabled,
)
.unwrap_or(SandboxType::None)
} else {
SandboxType::None
}
}
_ => get_platform_sandbox(
windows_sandbox_level != codex_protocol::config_types::WindowsSandboxLevel::Disabled,
@@ -200,11 +210,13 @@ pub async fn process_exec_tool_call(
};
let manager = SandboxManager::new();
let exec_env = manager
let exec_req = manager
.transform(crate::sandboxing::SandboxTransformRequest {
spec,
policy: sandbox_policy,
sandbox: sandbox_type,
enforce_managed_network,
network: network.as_ref(),
sandbox_policy_cwd: sandbox_cwd,
codex_linux_sandbox_exe: codex_linux_sandbox_exe.as_ref(),
use_linux_sandbox_bwrap,
@@ -213,19 +225,19 @@ pub async fn process_exec_tool_call(
.map_err(CodexErr::from)?;
// Route through the sandboxing module for a single, unified execution path.
crate::sandboxing::execute_env(exec_env, sandbox_policy, network, stdout_stream).await
crate::sandboxing::execute_env(exec_req, sandbox_policy, stdout_stream).await
}
pub(crate) async fn execute_exec_env(
env: ExecEnv,
env: ExecRequest,
sandbox_policy: &SandboxPolicy,
stdout_stream: Option<StdoutStream>,
network: Option<NetworkProxy>,
) -> Result<ExecToolCallOutput> {
let ExecEnv {
let ExecRequest {
command,
cwd,
mut env,
env,
network,
expiration,
sandbox,
windows_sandbox_level,
@@ -234,10 +246,6 @@ pub(crate) async fn execute_exec_env(
arg0,
} = env;
if let Some(network) = network.as_ref() {
network.apply_to_env(&mut env);
}
let params = ExecParams {
command,
cwd,
@@ -694,7 +702,7 @@ async fn exec(
command,
cwd,
env,
network: _,
network,
arg0,
expiration,
windows_sandbox_level: _,
@@ -708,15 +716,16 @@ async fn exec(
))
})?;
let arg0_ref = arg0.as_deref();
let child = spawn_child_async(
PathBuf::from(program),
args.into(),
arg0_ref,
let child = spawn_child_async(SpawnChildRequest {
program: PathBuf::from(program),
args: args.into(),
arg0: arg0_ref,
cwd,
sandbox_policy,
StdioPolicy::RedirectForShellTool,
network: network.as_ref(),
stdio_policy: StdioPolicy::RedirectForShellTool,
env,
)
})
.await?;
consume_truncated_output(child, expiration, stdout_stream).await
}
+42 -6
View File
@@ -1,6 +1,8 @@
use crate::protocol::SandboxPolicy;
use crate::spawn::SpawnChildRequest;
use crate::spawn::StdioPolicy;
use crate::spawn::spawn_child_async;
use codex_network_proxy::NetworkProxy;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
@@ -23,6 +25,7 @@ pub async fn spawn_command_under_linux_sandbox<P>(
sandbox_policy_cwd: &Path,
use_bwrap_sandbox: bool,
stdio_policy: StdioPolicy,
network: Option<&NetworkProxy>,
env: HashMap<String, String>,
) -> std::io::Result<Child>
where
@@ -33,20 +36,29 @@ where
sandbox_policy,
sandbox_policy_cwd,
use_bwrap_sandbox,
allow_network_for_proxy(false),
);
let arg0 = Some("codex-linux-sandbox");
spawn_child_async(
codex_linux_sandbox_exe.as_ref().to_path_buf(),
spawn_child_async(SpawnChildRequest {
program: codex_linux_sandbox_exe.as_ref().to_path_buf(),
args,
arg0,
command_cwd,
cwd: command_cwd,
sandbox_policy,
network,
stdio_policy,
env,
)
})
.await
}
pub(crate) fn allow_network_for_proxy(enforce_managed_network: bool) -> bool {
// When managed network requirements are active, request proxy-only
// networking from the Linux sandbox helper. Without managed requirements,
// preserve existing behavior.
enforce_managed_network
}
/// Converts the sandbox policy into the CLI invocation for `codex-linux-sandbox`.
///
/// The helper performs the actual sandboxing (bubblewrap + seccomp) after
@@ -56,6 +68,7 @@ pub(crate) fn create_linux_sandbox_command_args(
sandbox_policy: &SandboxPolicy,
sandbox_policy_cwd: &Path,
use_bwrap_sandbox: bool,
allow_network_for_proxy: bool,
) -> Vec<String> {
#[expect(clippy::expect_used)]
let sandbox_policy_cwd = sandbox_policy_cwd
@@ -76,6 +89,9 @@ pub(crate) fn create_linux_sandbox_command_args(
if use_bwrap_sandbox {
linux_cmd.push("--use-bwrap-sandbox".to_string());
}
if allow_network_for_proxy {
linux_cmd.push("--allow-network-for-proxy".to_string());
}
// Separator so that command arguments starting with `-` are not parsed as
// options of the helper itself.
@@ -98,16 +114,36 @@ mod tests {
let cwd = Path::new("/tmp");
let policy = SandboxPolicy::ReadOnly;
let with_bwrap = create_linux_sandbox_command_args(command.clone(), &policy, cwd, true);
let with_bwrap =
create_linux_sandbox_command_args(command.clone(), &policy, cwd, true, false);
assert_eq!(
with_bwrap.contains(&"--use-bwrap-sandbox".to_string()),
true
);
let without_bwrap = create_linux_sandbox_command_args(command, &policy, cwd, false);
let without_bwrap = create_linux_sandbox_command_args(command, &policy, cwd, false, false);
assert_eq!(
without_bwrap.contains(&"--use-bwrap-sandbox".to_string()),
false
);
}
#[test]
fn proxy_flag_is_included_when_requested() {
let command = vec!["/bin/true".to_string()];
let cwd = Path::new("/tmp");
let policy = SandboxPolicy::ReadOnly;
let args = create_linux_sandbox_command_args(command, &policy, cwd, true, true);
assert_eq!(
args.contains(&"--allow-network-for-proxy".to_string()),
true
);
}
#[test]
fn proxy_network_requires_managed_requirements() {
assert_eq!(allow_network_for_proxy(false), false);
assert_eq!(allow_network_for_proxy(true), true);
}
}
+68 -10
View File
@@ -1,7 +1,7 @@
/*
Module: sandboxing
Build platform wrappers and produce ExecEnv for execution. Owns lowlevel
Build platform wrappers and produce ExecRequest for execution. Owns low-level
sandbox placement and transformation of portable CommandSpec into a
readytospawn environment.
*/
@@ -11,6 +11,7 @@ use crate::exec::ExecToolCallOutput;
use crate::exec::SandboxType;
use crate::exec::StdoutStream;
use crate::exec::execute_exec_env;
use crate::landlock::allow_network_for_proxy;
use crate::landlock::create_linux_sandbox_command_args;
use crate::protocol::SandboxPolicy;
#[cfg(target_os = "macos")]
@@ -40,10 +41,11 @@ pub struct CommandSpec {
}
#[derive(Debug)]
pub struct ExecEnv {
pub struct ExecRequest {
pub command: Vec<String>,
pub cwd: PathBuf,
pub env: HashMap<String, String>,
pub network: Option<NetworkProxy>,
pub expiration: ExecExpiration,
pub sandbox: SandboxType,
pub windows_sandbox_level: WindowsSandboxLevel,
@@ -59,6 +61,10 @@ pub(crate) struct SandboxTransformRequest<'a> {
pub spec: CommandSpec,
pub policy: &'a SandboxPolicy,
pub sandbox: SandboxType,
pub enforce_managed_network: bool,
// TODO(viyatb): Evaluate switching this to Option<Arc<NetworkProxy>>
// to make shared ownership explicit across runtime/sandbox plumbing.
pub network: Option<&'a NetworkProxy>,
pub sandbox_policy_cwd: &'a Path,
pub codex_linux_sandbox_exe: Option<&'a PathBuf>,
pub use_linux_sandbox_bwrap: bool,
@@ -93,6 +99,7 @@ impl SandboxManager {
policy: &SandboxPolicy,
pref: SandboxablePreference,
windows_sandbox_level: WindowsSandboxLevel,
has_managed_network_requirements: bool,
) -> SandboxType {
match pref {
SandboxablePreference::Forbid => SandboxType::None,
@@ -106,7 +113,14 @@ impl SandboxManager {
}
SandboxablePreference::Auto => match policy {
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {
SandboxType::None
if has_managed_network_requirements {
crate::safety::get_platform_sandbox(
windows_sandbox_level != WindowsSandboxLevel::Disabled,
)
.unwrap_or(SandboxType::None)
} else {
SandboxType::None
}
}
_ => crate::safety::get_platform_sandbox(
windows_sandbox_level != WindowsSandboxLevel::Disabled,
@@ -119,11 +133,13 @@ impl SandboxManager {
pub(crate) fn transform(
&self,
request: SandboxTransformRequest<'_>,
) -> Result<ExecEnv, SandboxTransformError> {
) -> Result<ExecRequest, SandboxTransformError> {
let SandboxTransformRequest {
mut spec,
policy,
sandbox,
enforce_managed_network,
network,
sandbox_policy_cwd,
codex_linux_sandbox_exe,
use_linux_sandbox_bwrap,
@@ -147,8 +163,13 @@ impl SandboxManager {
SandboxType::MacosSeatbelt => {
let mut seatbelt_env = HashMap::new();
seatbelt_env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string());
let mut args =
create_seatbelt_command_args(command.clone(), policy, sandbox_policy_cwd);
let mut args = create_seatbelt_command_args(
command.clone(),
policy,
sandbox_policy_cwd,
enforce_managed_network,
network,
);
let mut full_command = Vec::with_capacity(1 + args.len());
full_command.push(MACOS_PATH_TO_SEATBELT_EXECUTABLE.to_string());
full_command.append(&mut args);
@@ -159,11 +180,13 @@ impl SandboxManager {
SandboxType::LinuxSeccomp => {
let exe = codex_linux_sandbox_exe
.ok_or(SandboxTransformError::MissingLinuxSandboxExecutable)?;
let allow_proxy_network = allow_network_for_proxy(enforce_managed_network);
let mut args = create_linux_sandbox_command_args(
command.clone(),
policy,
sandbox_policy_cwd,
use_linux_sandbox_bwrap,
allow_proxy_network,
);
let mut full_command = Vec::with_capacity(1 + args.len());
full_command.push(exe.to_string_lossy().to_string());
@@ -186,10 +209,11 @@ impl SandboxManager {
env.extend(sandbox_env);
Ok(ExecEnv {
Ok(ExecRequest {
command,
cwd: spec.cwd,
env,
network: network.cloned(),
expiration: spec.expiration,
sandbox,
windows_sandbox_level,
@@ -205,10 +229,44 @@ impl SandboxManager {
}
pub async fn execute_env(
env: ExecEnv,
env: ExecRequest,
policy: &SandboxPolicy,
network: Option<NetworkProxy>,
stdout_stream: Option<StdoutStream>,
) -> crate::error::Result<ExecToolCallOutput> {
execute_exec_env(env, policy, stdout_stream, network).await
execute_exec_env(env, policy, stdout_stream).await
}
#[cfg(test)]
mod tests {
use super::SandboxManager;
use crate::exec::SandboxType;
use crate::protocol::SandboxPolicy;
use crate::tools::sandboxing::SandboxablePreference;
use codex_protocol::config_types::WindowsSandboxLevel;
use pretty_assertions::assert_eq;
#[test]
fn danger_full_access_defaults_to_no_sandbox_without_network_requirements() {
let manager = SandboxManager::new();
let sandbox = manager.select_initial(
&SandboxPolicy::DangerFullAccess,
SandboxablePreference::Auto,
WindowsSandboxLevel::Disabled,
false,
);
assert_eq!(sandbox, SandboxType::None);
}
#[test]
fn danger_full_access_uses_platform_sandbox_with_network_requirements() {
let manager = SandboxManager::new();
let expected = crate::safety::get_platform_sandbox(false).unwrap_or(SandboxType::None);
let sandbox = manager.select_initial(
&SandboxPolicy::DangerFullAccess,
SandboxablePreference::Auto,
WindowsSandboxLevel::Disabled,
true,
);
assert_eq!(sandbox, expected);
}
}
+297 -17
View File
@@ -1,13 +1,21 @@
#![cfg(target_os = "macos")]
use codex_network_proxy::ALLOW_LOCAL_BINDING_ENV_KEY;
use codex_network_proxy::NetworkProxy;
use codex_network_proxy::PROXY_URL_ENV_KEYS;
use codex_network_proxy::has_proxy_url_env_vars;
use codex_network_proxy::proxy_url_env_value;
use std::collections::BTreeSet;
use std::collections::HashMap;
use std::ffi::CStr;
use std::path::Path;
use std::path::PathBuf;
use tokio::process::Child;
use url::Url;
use crate::protocol::SandboxPolicy;
use crate::spawn::CODEX_SANDBOX_ENV_VAR;
use crate::spawn::SpawnChildRequest;
use crate::spawn::StdioPolicy;
use crate::spawn::spawn_child_async;
@@ -26,27 +34,151 @@ pub async fn spawn_command_under_seatbelt(
sandbox_policy: &SandboxPolicy,
sandbox_policy_cwd: &Path,
stdio_policy: StdioPolicy,
network: Option<&NetworkProxy>,
mut env: HashMap<String, String>,
) -> std::io::Result<Child> {
let args = create_seatbelt_command_args(command, sandbox_policy, sandbox_policy_cwd);
let args =
create_seatbelt_command_args(command, sandbox_policy, sandbox_policy_cwd, false, network);
let arg0 = None;
env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string());
spawn_child_async(
PathBuf::from(MACOS_PATH_TO_SEATBELT_EXECUTABLE),
spawn_child_async(SpawnChildRequest {
program: PathBuf::from(MACOS_PATH_TO_SEATBELT_EXECUTABLE),
args,
arg0,
command_cwd,
cwd: command_cwd,
sandbox_policy,
network,
stdio_policy,
env,
)
})
.await
}
fn is_loopback_host(host: &str) -> bool {
host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1"
}
fn proxy_scheme_default_port(scheme: &str) -> u16 {
match scheme {
"https" => 443,
"socks5" | "socks5h" | "socks4" | "socks4a" => 1080,
_ => 80,
}
}
fn proxy_loopback_ports_from_env(env: &HashMap<String, String>) -> Vec<u16> {
let mut ports = BTreeSet::new();
for key in PROXY_URL_ENV_KEYS {
let Some(proxy_url) = proxy_url_env_value(env, key) else {
continue;
};
let trimmed = proxy_url.trim();
if trimmed.is_empty() {
continue;
}
let candidate = if trimmed.contains("://") {
trimmed.to_string()
} else {
format!("http://{trimmed}")
};
let Ok(parsed) = Url::parse(&candidate) else {
continue;
};
let Some(host) = parsed.host_str() else {
continue;
};
if !is_loopback_host(host) {
continue;
}
let scheme = parsed.scheme().to_ascii_lowercase();
let port = parsed
.port()
.unwrap_or_else(|| proxy_scheme_default_port(scheme.as_str()));
ports.insert(port);
}
ports.into_iter().collect()
}
fn local_binding_enabled(env: &HashMap<String, String>) -> bool {
env.get(ALLOW_LOCAL_BINDING_ENV_KEY).is_some_and(|value| {
let trimmed = value.trim();
trimmed == "1" || trimmed.eq_ignore_ascii_case("true")
})
}
#[derive(Debug, Default)]
struct ProxyPolicyInputs {
ports: Vec<u16>,
has_proxy_config: bool,
allow_local_binding: bool,
}
fn proxy_policy_inputs(network: Option<&NetworkProxy>) -> ProxyPolicyInputs {
if let Some(network) = network {
let mut env = HashMap::new();
network.apply_to_env(&mut env);
return ProxyPolicyInputs {
ports: proxy_loopback_ports_from_env(&env),
has_proxy_config: has_proxy_url_env_vars(&env),
allow_local_binding: local_binding_enabled(&env),
};
}
ProxyPolicyInputs::default()
}
fn dynamic_network_policy(
sandbox_policy: &SandboxPolicy,
enforce_managed_network: bool,
proxy: &ProxyPolicyInputs,
) -> String {
if !proxy.ports.is_empty() {
let mut policy =
String::from("; allow outbound access only to configured loopback proxy endpoints\n");
if proxy.allow_local_binding {
policy.push_str("; allow localhost-only binding and loopback traffic\n");
policy.push_str("(allow network-bind (local ip \"localhost:*\"))\n");
policy.push_str("(allow network-inbound (local ip \"localhost:*\"))\n");
policy.push_str("(allow network-outbound (remote ip \"localhost:*\"))\n");
}
for port in &proxy.ports {
policy.push_str(&format!(
"(allow network-outbound (remote ip \"localhost:{port}\"))\n"
));
}
return format!("{policy}{MACOS_SEATBELT_NETWORK_POLICY}");
}
if proxy.has_proxy_config {
// Proxy configuration is present but we could not infer any valid loopback endpoints.
// Fail closed to avoid silently widening network access in proxy-enforced sessions.
return String::new();
}
if enforce_managed_network {
// Managed network requirements are active but no usable proxy endpoints
// are available. Fail closed for network access.
return String::new();
}
if sandbox_policy.has_full_network_access() {
// No proxy env is configured: retain the existing full-network behavior.
format!(
"(allow network-outbound)\n(allow network-inbound)\n{MACOS_SEATBELT_NETWORK_POLICY}"
)
} else {
String::new()
}
}
pub(crate) fn create_seatbelt_command_args(
command: Vec<String>,
sandbox_policy: &SandboxPolicy,
sandbox_policy_cwd: &Path,
enforce_managed_network: bool,
network: Option<&NetworkProxy>,
) -> Vec<String> {
let (file_write_policy, file_write_dir_params) = {
if sandbox_policy.has_full_disk_write_access() {
@@ -112,11 +244,8 @@ pub(crate) fn create_seatbelt_command_args(
};
// TODO(mbolin): apply_patch calls must also honor the SandboxPolicy.
let network_policy = if sandbox_policy.has_full_network_access() {
MACOS_SEATBELT_NETWORK_POLICY
} else {
""
};
let proxy = proxy_policy_inputs(network);
let network_policy = dynamic_network_policy(sandbox_policy, enforce_managed_network, &proxy);
let full_policy = format!(
"{MACOS_SEATBELT_BASE_POLICY}\n{file_read_policy}\n{file_write_policy}\n{network_policy}"
@@ -163,7 +292,9 @@ fn macos_dir_params() -> Vec<(String, PathBuf)> {
#[cfg(test)]
mod tests {
use super::MACOS_SEATBELT_BASE_POLICY;
use super::ProxyPolicyInputs;
use super::create_seatbelt_command_args;
use super::dynamic_network_policy;
use super::macos_dir_params;
use crate::protocol::SandboxPolicy;
use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE;
@@ -184,6 +315,148 @@ mod tests {
);
}
#[test]
fn create_seatbelt_args_routes_network_through_proxy_ports() {
let policy = dynamic_network_policy(
&SandboxPolicy::ReadOnly,
false,
&ProxyPolicyInputs {
ports: vec![43128, 48081],
has_proxy_config: true,
allow_local_binding: false,
},
);
assert!(
policy.contains("(allow network-outbound (remote ip \"localhost:43128\"))"),
"expected HTTP proxy port allow rule in policy:\n{policy}"
);
assert!(
policy.contains("(allow network-outbound (remote ip \"localhost:48081\"))"),
"expected SOCKS proxy port allow rule in policy:\n{policy}"
);
assert!(
!policy.contains("\n(allow network-outbound)\n"),
"policy should not include blanket outbound allowance when proxy ports are present:\n{policy}"
);
assert!(
!policy.contains("(allow network-bind (local ip \"localhost:*\"))"),
"policy should not allow loopback binding unless explicitly enabled:\n{policy}"
);
assert!(
!policy.contains("(allow network-inbound (local ip \"localhost:*\"))"),
"policy should not allow loopback inbound unless explicitly enabled:\n{policy}"
);
}
#[test]
fn create_seatbelt_args_allows_local_binding_when_explicitly_enabled() {
let policy = dynamic_network_policy(
&SandboxPolicy::ReadOnly,
false,
&ProxyPolicyInputs {
ports: vec![43128],
has_proxy_config: true,
allow_local_binding: true,
},
);
assert!(
policy.contains("(allow network-bind (local ip \"localhost:*\"))"),
"policy should allow loopback binding when explicitly enabled:\n{policy}"
);
assert!(
policy.contains("(allow network-inbound (local ip \"localhost:*\"))"),
"policy should allow loopback inbound when explicitly enabled:\n{policy}"
);
assert!(
policy.contains("(allow network-outbound (remote ip \"localhost:*\"))"),
"policy should allow loopback outbound when explicitly enabled:\n{policy}"
);
assert!(
!policy.contains("\n(allow network-outbound)\n"),
"policy should keep proxy-routed behavior without blanket outbound allowance:\n{policy}"
);
}
#[test]
fn dynamic_network_policy_fails_closed_when_proxy_config_without_ports() {
let policy = dynamic_network_policy(
&SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: true,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
},
false,
&ProxyPolicyInputs {
ports: vec![],
has_proxy_config: true,
allow_local_binding: false,
},
);
assert!(
!policy.contains("\n(allow network-outbound)\n"),
"policy should not include blanket outbound allowance when proxy config is present without ports:\n{policy}"
);
assert!(
!policy.contains("(allow network-outbound (remote ip \"localhost:"),
"policy should not include proxy port allowance when proxy config is present without ports:\n{policy}"
);
}
#[test]
fn dynamic_network_policy_fails_closed_for_managed_network_without_proxy_config() {
let policy = dynamic_network_policy(
&SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: true,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
},
true,
&ProxyPolicyInputs {
ports: vec![],
has_proxy_config: false,
allow_local_binding: false,
},
);
assert_eq!(policy, "");
}
#[test]
fn create_seatbelt_args_full_network_with_proxy_is_still_proxy_only() {
let policy = dynamic_network_policy(
&SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: true,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
},
false,
&ProxyPolicyInputs {
ports: vec![43128],
has_proxy_config: true,
allow_local_binding: false,
},
);
assert!(
policy.contains("(allow network-outbound (remote ip \"localhost:43128\"))"),
"expected proxy endpoint allow rule in policy:\n{policy}"
);
assert!(
!policy.contains("\n(allow network-outbound)\n"),
"policy should not include blanket outbound allowance when proxy is configured:\n{policy}"
);
assert!(
!policy.contains("\n(allow network-inbound)\n"),
"policy should not include blanket inbound allowance when proxy is configured:\n{policy}"
);
}
#[test]
fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() {
// Create a temporary workspace with two writable roots: one containing
@@ -227,7 +500,7 @@ mod tests {
.iter()
.map(std::string::ToString::to_string)
.collect();
let args = create_seatbelt_command_args(shell_command.clone(), &policy, &cwd);
let args = create_seatbelt_command_args(shell_command.clone(), &policy, &cwd, false, None);
// Build the expected policy text using a raw string for readability.
// Note that the policy includes:
@@ -315,7 +588,8 @@ mod tests {
.iter()
.map(std::string::ToString::to_string)
.collect();
let write_hooks_file_args = create_seatbelt_command_args(shell_command_git, &policy, &cwd);
let write_hooks_file_args =
create_seatbelt_command_args(shell_command_git, &policy, &cwd, false, None);
let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
.args(&write_hooks_file_args)
.current_dir(&cwd)
@@ -346,7 +620,7 @@ mod tests {
.map(std::string::ToString::to_string)
.collect();
let write_allowed_file_args =
create_seatbelt_command_args(shell_command_allowed, &policy, &cwd);
create_seatbelt_command_args(shell_command_allowed, &policy, &cwd, false, None);
let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
.args(&write_allowed_file_args)
.current_dir(&cwd)
@@ -406,7 +680,7 @@ mod tests {
.iter()
.map(std::string::ToString::to_string)
.collect();
let args = create_seatbelt_command_args(shell_command, &policy, &cwd);
let args = create_seatbelt_command_args(shell_command, &policy, &cwd, false, None);
let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
.args(&args)
@@ -436,7 +710,8 @@ mod tests {
.iter()
.map(std::string::ToString::to_string)
.collect();
let gitdir_args = create_seatbelt_command_args(shell_command_gitdir, &policy, &cwd);
let gitdir_args =
create_seatbelt_command_args(shell_command_gitdir, &policy, &cwd, false, None);
let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
.args(&gitdir_args)
.current_dir(&cwd)
@@ -492,8 +767,13 @@ mod tests {
.iter()
.map(std::string::ToString::to_string)
.collect();
let args =
create_seatbelt_command_args(shell_command.clone(), &policy, vulnerable_root.as_path());
let args = create_seatbelt_command_args(
shell_command.clone(),
&policy,
vulnerable_root.as_path(),
false,
None,
);
let tmpdir_env_var = std::env::var("TMPDIR")
.ok()
@@ -1,9 +1,14 @@
; when network access is enabled, these policies are added after those in seatbelt_base_policy.sbpl
; proxy-specific allow rules are injected by codex-core based on environment.
; Ref https://source.chromium.org/chromium/chromium/src/+/main:sandbox/policy/mac/network.sb;drc=f8f264d5e4e7509c913f4c60c2639d15905a07e4
(allow network-outbound)
(allow network-inbound)
(allow system-socket)
; allow only safe AF_SYSTEM sockets used for local platform services.
(allow system-socket
(require-all
(socket-domain AF_SYSTEM)
(socket-protocol 2)
)
)
(allow mach-lookup
; Used to look up the _CS_DARWIN_USER_CACHE_DIR in the sandbox.
+27 -9
View File
@@ -1,3 +1,4 @@
use codex_network_proxy::NetworkProxy;
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Stdio;
@@ -35,15 +36,29 @@ pub enum StdioPolicy {
/// For now, we take `SandboxPolicy` as a parameter to spawn_child() because
/// we need to determine whether to set the
/// `CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR` environment variable.
pub(crate) async fn spawn_child_async(
program: PathBuf,
args: Vec<String>,
#[cfg_attr(not(unix), allow(unused_variables))] arg0: Option<&str>,
cwd: PathBuf,
sandbox_policy: &SandboxPolicy,
stdio_policy: StdioPolicy,
env: HashMap<String, String>,
) -> std::io::Result<Child> {
pub(crate) struct SpawnChildRequest<'a> {
pub program: PathBuf,
pub args: Vec<String>,
pub arg0: Option<&'a str>,
pub cwd: PathBuf,
pub sandbox_policy: &'a SandboxPolicy,
pub network: Option<&'a NetworkProxy>,
pub stdio_policy: StdioPolicy,
pub env: HashMap<String, String>,
}
pub(crate) async fn spawn_child_async(request: SpawnChildRequest<'_>) -> std::io::Result<Child> {
let SpawnChildRequest {
program,
args,
arg0,
cwd,
sandbox_policy,
network,
stdio_policy,
mut env,
} = request;
trace!(
"spawn_child_async: {program:?} {args:?} {arg0:?} {cwd:?} {sandbox_policy:?} {stdio_policy:?} {env:?}"
);
@@ -53,6 +68,9 @@ pub(crate) async fn spawn_child_async(
cmd.arg0(arg0.map_or_else(|| program.to_string_lossy().to_string(), String::from));
cmd.args(args);
cmd.current_dir(cwd);
if let Some(network) = network {
network.apply_to_env(&mut env);
}
cmd.env_clear();
cmd.envs(env);
+6 -10
View File
@@ -23,7 +23,7 @@ use crate::protocol::ExecCommandEndEvent;
use crate::protocol::ExecCommandSource;
use crate::protocol::SandboxPolicy;
use crate::protocol::TurnStartedEvent;
use crate::sandboxing::ExecEnv;
use crate::sandboxing::ExecRequest;
use crate::sandboxing::SandboxPermissions;
use crate::state::TaskKind;
use crate::tools::format_exec_output_str;
@@ -140,13 +140,14 @@ pub(crate) async fn execute_user_shell_command(
)
.await;
let exec_env = ExecEnv {
let exec_env = ExecRequest {
command: exec_command.clone(),
cwd: cwd.clone(),
env: create_env(
&turn_context.shell_environment_policy,
Some(session.conversation_id),
),
network: turn_context.config.network.clone(),
// TODO(zhao-oai): Now that we have ExecExpiration::Cancellation, we
// should use that instead of an "arbitrarily large" timeout here.
expiration: USER_SHELL_TIMEOUT_MS.into(),
@@ -164,14 +165,9 @@ pub(crate) async fn execute_user_shell_command(
});
let sandbox_policy = SandboxPolicy::DangerFullAccess;
let exec_result = execute_exec_env(
exec_env,
&sandbox_policy,
stdout_stream,
turn_context.config.network.clone(),
)
.or_cancel(&cancellation_token)
.await;
let exec_result = execute_exec_env(exec_env, &sandbox_policy, stdout_stream)
.or_cancel(&cancellation_token)
.await;
match exec_result {
Err(CancelErr::Cancelled) => {
@@ -192,6 +192,7 @@ impl ToolHandler for UnifiedExecHandler {
yield_time_ms,
max_output_tokens,
workdir,
network: context.turn.config.network.clone(),
tty,
sandbox_permissions,
justification,
+12 -2
View File
@@ -3,7 +3,8 @@ Module: orchestrator
Central place for approvals + sandbox selection + retry semantics. Drives a
simple sequence for any ToolRuntime: approval → select sandbox → attempt →
retry without sandbox on denial (no reapproval thanks to caching).
retry with an escalated sandbox strategy on denial (no reapproval thanks to
caching).
*/
use crate::error::CodexErr;
use crate::error::SandboxErr;
@@ -87,12 +88,19 @@ impl ToolOrchestrator {
}
// 2) First attempt under the selected sandbox.
let has_managed_network_requirements = turn_ctx
.config
.config_layer_stack
.requirements_toml()
.network
.is_some();
let initial_sandbox = match tool.sandbox_mode_for_first_attempt(req) {
SandboxOverride::BypassSandboxFirstAttempt => crate::exec::SandboxType::None,
SandboxOverride::NoOverride => self.sandbox.select_initial(
&turn_ctx.sandbox_policy,
tool.sandbox_preference(),
turn_ctx.windows_sandbox_level,
has_managed_network_requirements,
),
};
@@ -102,6 +110,7 @@ impl ToolOrchestrator {
let initial_attempt = SandboxAttempt {
sandbox: initial_sandbox,
policy: &turn_ctx.sandbox_policy,
enforce_managed_network: has_managed_network_requirements,
manager: &self.sandbox,
sandbox_cwd: &turn_ctx.cwd,
codex_linux_sandbox_exe: turn_ctx.codex_linux_sandbox_exe.as_ref(),
@@ -128,7 +137,7 @@ impl ToolOrchestrator {
})));
}
// Ask for approval before retrying without sandbox.
// Ask for approval before retrying with the escalated sandbox.
if !tool.should_bypass_approval(approval_policy, already_approved) {
let reason_msg = build_denial_reason_from_output(output.as_ref());
let approval_ctx = ApprovalCtx {
@@ -154,6 +163,7 @@ impl ToolOrchestrator {
let escalated_attempt = SandboxAttempt {
sandbox: crate::exec::SandboxType::None,
policy: &turn_ctx.sandbox_policy,
enforce_managed_network: has_managed_network_requirements,
manager: &self.sandbox,
sandbox_cwd: &turn_ctx.cwd,
codex_linux_sandbox_exe: None,
@@ -151,9 +151,9 @@ impl ToolRuntime<ApplyPatchRequest, ExecToolCallOutput> for ApplyPatchRuntime {
) -> Result<ExecToolCallOutput, ToolError> {
let spec = Self::build_command_spec(req)?;
let env = attempt
.env_for(spec)
.env_for(spec, None)
.map_err(|err| ToolError::Codex(err.into()))?;
let out = execute_env(env, attempt.policy, None, Self::stdout_stream(ctx))
let out = execute_env(env, attempt.policy, Self::stdout_stream(ctx))
.await
.map_err(ToolError::Codex)?;
Ok(out)
+4 -9
View File
@@ -167,16 +167,11 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
req.justification.clone(),
)?;
let env = attempt
.env_for(spec)
.env_for(spec, req.network.as_ref())
.map_err(|err| ToolError::Codex(err.into()))?;
let out = execute_env(
env,
attempt.policy,
req.network.clone(),
Self::stdout_stream(ctx),
)
.await
.map_err(ToolError::Codex)?;
let out = execute_env(env, attempt.policy, Self::stdout_stream(ctx))
.await
.map_err(ToolError::Codex)?;
Ok(out)
}
}
@@ -2,7 +2,7 @@
Runtime: unified exec
Handles approval + sandbox orchestration for unified exec requests, delegating to
the process manager to spawn PTYs once an ExecEnv is prepared.
the process manager to spawn PTYs once an ExecRequest is prepared.
*/
use crate::error::CodexErr;
use crate::error::SandboxErr;
@@ -176,7 +176,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
)
.map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?;
let exec_env = attempt
.env_for(spec)
.env_for(spec, req.network.as_ref())
.map_err(|err| ToolError::Codex(err.into()))?;
self.manager
.open_session_with_exec_env(&exec_env, req.tty)
+6 -1
View File
@@ -12,6 +12,7 @@ use crate::sandboxing::CommandSpec;
use crate::sandboxing::SandboxManager;
use crate::sandboxing::SandboxTransformError;
use crate::state::SessionServices;
use codex_network_proxy::NetworkProxy;
use codex_protocol::approvals::ExecPolicyAmendment;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::ReviewDecision;
@@ -271,6 +272,7 @@ pub(crate) trait ToolRuntime<Req, Out>: Approvable<Req> + Sandboxable {
pub(crate) struct SandboxAttempt<'a> {
pub sandbox: crate::exec::SandboxType,
pub policy: &'a crate::protocol::SandboxPolicy,
pub enforce_managed_network: bool,
pub(crate) manager: &'a SandboxManager,
pub(crate) sandbox_cwd: &'a Path,
pub codex_linux_sandbox_exe: Option<&'a std::path::PathBuf>,
@@ -282,12 +284,15 @@ impl<'a> SandboxAttempt<'a> {
pub fn env_for(
&self,
spec: CommandSpec,
) -> Result<crate::sandboxing::ExecEnv, SandboxTransformError> {
network: Option<&NetworkProxy>,
) -> Result<crate::sandboxing::ExecRequest, SandboxTransformError> {
self.manager
.transform(crate::sandboxing::SandboxTransformRequest {
spec,
policy: self.policy,
sandbox: self.sandbox,
enforce_managed_network: self.enforce_managed_network,
network,
sandbox_policy_cwd: self.sandbox_cwd,
codex_linux_sandbox_exe: self.codex_linux_sandbox_exe,
use_linux_sandbox_bwrap: self.use_linux_sandbox_bwrap,
+5 -2
View File
@@ -4,7 +4,7 @@
//! - Manages interactive processes (create, reuse, buffer output with caps).
//! - Uses the shared ToolOrchestrator to handle approval, sandbox selection, and
//! retry semantics in a single, descriptive flow.
//! - Spawns the PTY from a sandboxtransformed `ExecEnv`; on sandbox denial,
//! - Spawns the PTY from a sandbox-transformed `ExecRequest`; on sandbox denial,
//! retries without sandbox when policy allows (no reprompt thanks to caching).
//! - Uses the shared `is_likely_sandbox_denied` heuristic to keep denial messages
//! consistent with other exec paths.
@@ -12,7 +12,7 @@
//! Flow at a glance (open process)
//! 1) Build a small request `{ command, cwd }`.
//! 2) Orchestrator: approval (bypass/cache/prompt) → select sandbox → run.
//! 3) Runtime: transform `CommandSpec` `ExecEnv` → spawn PTY.
//! 3) Runtime: transform `CommandSpec` -> `ExecRequest` -> spawn PTY.
//! 4) If denial, orchestrator retries with `SandboxType::None`.
//! 5) Process handle is returned with streaming output + metadata.
//!
@@ -27,6 +27,7 @@ use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use codex_network_proxy::NetworkProxy;
use rand::Rng;
use rand::rng;
use tokio::sync::Mutex;
@@ -79,6 +80,7 @@ pub(crate) struct ExecCommandRequest {
pub yield_time_ms: u64,
pub max_output_tokens: Option<usize>,
pub workdir: Option<PathBuf>,
pub network: Option<NetworkProxy>,
pub tty: bool,
pub sandbox_permissions: SandboxPermissions,
pub justification: Option<String>,
@@ -203,6 +205,7 @@ mod tests {
yield_time_ms,
max_output_tokens: None,
workdir: None,
network: None,
tty: true,
sandbox_permissions: SandboxPermissions::UseDefault,
justification: None,
@@ -14,7 +14,7 @@ use tokio_util::sync::CancellationToken;
use crate::exec_env::create_env;
use crate::exec_policy::ExecApprovalRequest;
use crate::protocol::ExecCommandSource;
use crate::sandboxing::ExecEnv;
use crate::sandboxing::ExecRequest;
use crate::tools::events::ToolEmitter;
use crate::tools::events::ToolEventCtx;
use crate::tools::events::ToolEventStage;
@@ -460,7 +460,7 @@ impl UnifiedExecProcessManager {
pub(crate) async fn open_session_with_exec_env(
&self,
env: &ExecEnv,
env: &ExecRequest,
tty: bool,
) -> Result<UnifiedExecProcess, UnifiedExecError> {
let (program, args) = env
@@ -520,7 +520,7 @@ impl UnifiedExecProcessManager {
command: request.command.clone(),
cwd,
env,
network: context.turn.config.network.clone(),
network: request.network.clone(),
tty: request.tty,
sandbox_permissions: request.sandbox_permissions,
justification: request.justification.clone(),