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:
parent
b61ea47e83
commit
3391e5ea86
24 changed files with 1046 additions and 122 deletions
|
|
@ -130,7 +130,10 @@ async fn run_command_under_sandbox(
|
|||
let sandbox_policy_cwd = cwd.clone();
|
||||
|
||||
let stdio_policy = StdioPolicy::Inherit;
|
||||
let env = create_env(&config.shell_environment_policy, None);
|
||||
let mut env = create_env(&config.shell_environment_policy, None);
|
||||
if let Some(network) = config.network.as_ref() {
|
||||
network.apply_to_env(&mut env);
|
||||
}
|
||||
|
||||
// Special-case Windows sandbox: execute and exit the process to emulate inherited stdio.
|
||||
if let SandboxType::Windows = sandbox_type {
|
||||
|
|
@ -222,6 +225,7 @@ async fn run_command_under_sandbox(
|
|||
config.sandbox_policy.get(),
|
||||
sandbox_policy_cwd.as_path(),
|
||||
stdio_policy,
|
||||
None,
|
||||
env,
|
||||
)
|
||||
.await?
|
||||
|
|
@ -241,6 +245,7 @@ async fn run_command_under_sandbox(
|
|||
sandbox_policy_cwd.as_path(),
|
||||
use_bwrap_sandbox,
|
||||
stdio_policy,
|
||||
None,
|
||||
env,
|
||||
)
|
||||
.await?
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/*
|
||||
Module: sandboxing
|
||||
|
||||
Build platform wrappers and produce ExecEnv for execution. Owns low‑level
|
||||
Build platform wrappers and produce ExecRequest for execution. Owns low-level
|
||||
sandbox placement and transformation of portable CommandSpec into a
|
||||
ready‑to‑spawn 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 re‑approval thanks to caching).
|
||||
retry with an escalated sandbox strategy on denial (no re‑approval 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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 sandbox‑transformed `ExecEnv`; on sandbox denial,
|
||||
//! - Spawns the PTY from a sandbox-transformed `ExecRequest`; on sandbox denial,
|
||||
//! retries without sandbox when policy allows (no re‑prompt 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(),
|
||||
|
|
|
|||
|
|
@ -190,6 +190,7 @@ assert os.read(master, 4) == b"ping""#
|
|||
&policy,
|
||||
sandbox_cwd.as_path(),
|
||||
StdioPolicy::RedirectForShellTool,
|
||||
None,
|
||||
HashMap::new(),
|
||||
)
|
||||
.await
|
||||
|
|
@ -242,6 +243,7 @@ async fn java_home_finds_runtime_under_seatbelt() {
|
|||
&policy,
|
||||
sandbox_cwd.as_path(),
|
||||
StdioPolicy::RedirectForShellTool,
|
||||
None,
|
||||
env,
|
||||
)
|
||||
.await
|
||||
|
|
@ -298,6 +300,7 @@ async fn touch(path: &Path, policy: &SandboxPolicy) -> bool {
|
|||
policy,
|
||||
sandbox_cwd.as_path(),
|
||||
StdioPolicy::RedirectForShellTool,
|
||||
None,
|
||||
HashMap::new(),
|
||||
)
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ async fn spawn_command_under_sandbox(
|
|||
sandbox_policy,
|
||||
sandbox_cwd,
|
||||
stdio_policy,
|
||||
None,
|
||||
env,
|
||||
)
|
||||
.await
|
||||
|
|
@ -52,6 +53,7 @@ async fn spawn_command_under_sandbox(
|
|||
sandbox_cwd,
|
||||
false,
|
||||
stdio_policy,
|
||||
None,
|
||||
env,
|
||||
)
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ into this binary.
|
|||
writable roots are blocked by mounting `/dev/null` on the symlink or first
|
||||
missing component.
|
||||
- When enabled, the helper isolates the PID namespace via `--unshare-pid`.
|
||||
- When enabled and network is restricted without proxy routing, the helper also
|
||||
isolates the network namespace via `--unshare-net`.
|
||||
- When enabled, it mounts a fresh `/proc` via `--proc /proc` by default, but
|
||||
you can skip this in restrictive container environments with `--no-proc`.
|
||||
|
||||
|
|
|
|||
|
|
@ -26,19 +26,47 @@ pub(crate) struct BwrapOptions {
|
|||
/// This is the secure default, but some restrictive container environments
|
||||
/// deny `--proc /proc` even when PID namespaces are available.
|
||||
pub mount_proc: bool,
|
||||
/// How networking should be configured inside the bubblewrap sandbox.
|
||||
pub network_mode: BwrapNetworkMode,
|
||||
}
|
||||
|
||||
impl Default for BwrapOptions {
|
||||
fn default() -> Self {
|
||||
Self { mount_proc: true }
|
||||
Self {
|
||||
mount_proc: true,
|
||||
network_mode: BwrapNetworkMode::FullAccess,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Network policy modes for bubblewrap.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub(crate) enum BwrapNetworkMode {
|
||||
/// Keep access to the host network namespace.
|
||||
#[default]
|
||||
FullAccess,
|
||||
/// Remove access to the host network namespace.
|
||||
Isolated,
|
||||
/// Intended proxy-only mode.
|
||||
///
|
||||
/// Bubblewrap does not currently enforce proxy-only egress, so this is
|
||||
/// treated as isolated for fail-closed behavior.
|
||||
ProxyOnly,
|
||||
}
|
||||
|
||||
impl BwrapNetworkMode {
|
||||
fn should_unshare_network(self) -> bool {
|
||||
!matches!(self, Self::FullAccess)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap a command with bubblewrap so the filesystem is read-only by default,
|
||||
/// with explicit writable roots and read-only subpaths layered afterward.
|
||||
///
|
||||
/// When the policy grants full disk write access, this returns `command`
|
||||
/// unchanged so we avoid unnecessary sandboxing overhead.
|
||||
/// When the policy grants full disk write access and full network access, this
|
||||
/// returns `command` unchanged so we avoid unnecessary sandboxing overhead.
|
||||
/// If network isolation is requested, we still wrap with bubblewrap so network
|
||||
/// namespace restrictions apply while preserving full filesystem access.
|
||||
pub(crate) fn create_bwrap_command_args(
|
||||
command: Vec<String>,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
|
|
@ -46,12 +74,37 @@ pub(crate) fn create_bwrap_command_args(
|
|||
options: BwrapOptions,
|
||||
) -> Result<Vec<String>> {
|
||||
if sandbox_policy.has_full_disk_write_access() {
|
||||
return Ok(command);
|
||||
return if options.network_mode == BwrapNetworkMode::FullAccess {
|
||||
Ok(command)
|
||||
} else {
|
||||
Ok(create_bwrap_flags_full_filesystem(command, options))
|
||||
};
|
||||
}
|
||||
|
||||
create_bwrap_flags(command, sandbox_policy, cwd, options)
|
||||
}
|
||||
|
||||
fn create_bwrap_flags_full_filesystem(command: Vec<String>, options: BwrapOptions) -> Vec<String> {
|
||||
let mut args = vec![
|
||||
"--new-session".to_string(),
|
||||
"--die-with-parent".to_string(),
|
||||
"--bind".to_string(),
|
||||
"/".to_string(),
|
||||
"/".to_string(),
|
||||
"--unshare-pid".to_string(),
|
||||
];
|
||||
if options.network_mode.should_unshare_network() {
|
||||
args.push("--unshare-net".to_string());
|
||||
}
|
||||
if options.mount_proc {
|
||||
args.push("--proc".to_string());
|
||||
args.push("/proc".to_string());
|
||||
}
|
||||
args.push("--".to_string());
|
||||
args.extend(command);
|
||||
args
|
||||
}
|
||||
|
||||
/// Build the bubblewrap flags (everything after `argv[0]`).
|
||||
fn create_bwrap_flags(
|
||||
command: Vec<String>,
|
||||
|
|
@ -65,6 +118,9 @@ fn create_bwrap_flags(
|
|||
args.extend(create_filesystem_args(sandbox_policy, cwd)?);
|
||||
// Isolate the PID namespace.
|
||||
args.push("--unshare-pid".to_string());
|
||||
if options.network_mode.should_unshare_network() {
|
||||
args.push("--unshare-net".to_string());
|
||||
}
|
||||
// Mount a fresh /proc unless the caller explicitly disables it.
|
||||
if options.mount_proc {
|
||||
args.push("--proc".to_string());
|
||||
|
|
@ -250,3 +306,59 @@ fn find_first_non_existent_component(target_path: &Path) -> Option<PathBuf> {
|
|||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn full_disk_write_full_network_returns_unwrapped_command() {
|
||||
let command = vec!["/bin/true".to_string()];
|
||||
let args = create_bwrap_command_args(
|
||||
command.clone(),
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
Path::new("/"),
|
||||
BwrapOptions {
|
||||
mount_proc: true,
|
||||
network_mode: BwrapNetworkMode::FullAccess,
|
||||
},
|
||||
)
|
||||
.expect("create bwrap args");
|
||||
|
||||
assert_eq!(args, command);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_disk_write_proxy_only_keeps_full_filesystem_but_unshares_network() {
|
||||
let command = vec!["/bin/true".to_string()];
|
||||
let args = create_bwrap_command_args(
|
||||
command,
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
Path::new("/"),
|
||||
BwrapOptions {
|
||||
mount_proc: true,
|
||||
network_mode: BwrapNetworkMode::ProxyOnly,
|
||||
},
|
||||
)
|
||||
.expect("create bwrap args");
|
||||
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"--new-session".to_string(),
|
||||
"--die-with-parent".to_string(),
|
||||
"--bind".to_string(),
|
||||
"/".to_string(),
|
||||
"/".to_string(),
|
||||
"--unshare-pid".to_string(),
|
||||
"--unshare-net".to_string(),
|
||||
"--proc".to_string(),
|
||||
"/proc".to_string(),
|
||||
"--".to_string(),
|
||||
"/bin/true".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,18 +42,22 @@ pub(crate) fn apply_sandbox_policy_to_current_thread(
|
|||
sandbox_policy: &SandboxPolicy,
|
||||
cwd: &Path,
|
||||
apply_landlock_fs: bool,
|
||||
allow_network_for_proxy: bool,
|
||||
) -> Result<()> {
|
||||
let install_network_seccomp =
|
||||
should_install_network_seccomp(sandbox_policy, allow_network_for_proxy);
|
||||
|
||||
// `PR_SET_NO_NEW_PRIVS` is required for seccomp, but it also prevents
|
||||
// setuid privilege elevation. Many `bwrap` deployments rely on setuid, so
|
||||
// we avoid this unless we need seccomp or we are explicitly using the
|
||||
// legacy Landlock filesystem pipeline.
|
||||
if !sandbox_policy.has_full_network_access()
|
||||
if install_network_seccomp
|
||||
|| (apply_landlock_fs && !sandbox_policy.has_full_disk_write_access())
|
||||
{
|
||||
set_no_new_privs()?;
|
||||
}
|
||||
|
||||
if !sandbox_policy.has_full_network_access() {
|
||||
if install_network_seccomp {
|
||||
install_network_seccomp_filter_on_current_thread()?;
|
||||
}
|
||||
|
||||
|
|
@ -72,6 +76,15 @@ pub(crate) fn apply_sandbox_policy_to_current_thread(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn should_install_network_seccomp(
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
allow_network_for_proxy: bool,
|
||||
) -> bool {
|
||||
// Managed-network sessions should remain fail-closed even for policies that
|
||||
// would normally grant full network access (for example, DangerFullAccess).
|
||||
!sandbox_policy.has_full_network_access() || allow_network_for_proxy
|
||||
}
|
||||
|
||||
/// Enable `PR_SET_NO_NEW_PRIVS` so seccomp can be applied safely.
|
||||
fn set_no_new_privs() -> Result<()> {
|
||||
let result = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) };
|
||||
|
|
@ -183,3 +196,38 @@ fn install_network_seccomp_filter_on_current_thread() -> std::result::Result<(),
|
|||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::should_install_network_seccomp;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn managed_network_enforces_seccomp_even_for_full_network_policy() {
|
||||
assert_eq!(
|
||||
should_install_network_seccomp(&SandboxPolicy::DangerFullAccess, true),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_network_policy_without_managed_network_skips_seccomp() {
|
||||
assert_eq!(
|
||||
should_install_network_seccomp(&SandboxPolicy::DangerFullAccess, false),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restricted_network_policy_always_installs_seccomp() {
|
||||
assert!(should_install_network_seccomp(
|
||||
&SandboxPolicy::ReadOnly,
|
||||
false
|
||||
));
|
||||
assert!(should_install_network_seccomp(
|
||||
&SandboxPolicy::ReadOnly,
|
||||
true
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use std::os::fd::FromRawFd;
|
|||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::bwrap::BwrapNetworkMode;
|
||||
use crate::bwrap::BwrapOptions;
|
||||
use crate::bwrap::create_bwrap_command_args;
|
||||
use crate::landlock::apply_sandbox_policy_to_current_thread;
|
||||
|
|
@ -40,6 +41,14 @@ pub struct LandlockCommand {
|
|||
#[arg(long = "apply-seccomp-then-exec", hide = true, default_value_t = false)]
|
||||
pub apply_seccomp_then_exec: bool,
|
||||
|
||||
/// Internal compatibility flag.
|
||||
///
|
||||
/// By default, restricted-network sandboxing uses isolated networking.
|
||||
/// If set, sandbox setup switches to proxy-only network mode
|
||||
/// (currently enforced the same as isolated networking).
|
||||
#[arg(long = "allow-network-for-proxy", hide = true, default_value_t = false)]
|
||||
pub allow_network_for_proxy: bool,
|
||||
|
||||
/// When set, skip mounting a fresh `/proc` even though PID isolation is
|
||||
/// still enabled. This is primarily intended for restrictive container
|
||||
/// environments that deny `--proc /proc`.
|
||||
|
|
@ -64,6 +73,7 @@ pub fn run_main() -> ! {
|
|||
sandbox_policy,
|
||||
use_bwrap_sandbox,
|
||||
apply_seccomp_then_exec,
|
||||
allow_network_for_proxy,
|
||||
no_proc,
|
||||
command,
|
||||
} = LandlockCommand::parse();
|
||||
|
|
@ -75,18 +85,24 @@ pub fn run_main() -> ! {
|
|||
// Inner stage: apply seccomp/no_new_privs after bubblewrap has already
|
||||
// established the filesystem view.
|
||||
if apply_seccomp_then_exec {
|
||||
if let Err(e) =
|
||||
apply_sandbox_policy_to_current_thread(&sandbox_policy, &sandbox_policy_cwd, false)
|
||||
{
|
||||
if let Err(e) = apply_sandbox_policy_to_current_thread(
|
||||
&sandbox_policy,
|
||||
&sandbox_policy_cwd,
|
||||
false,
|
||||
allow_network_for_proxy,
|
||||
) {
|
||||
panic!("error applying Linux sandbox restrictions: {e:?}");
|
||||
}
|
||||
exec_or_panic(command);
|
||||
}
|
||||
|
||||
if sandbox_policy.has_full_disk_write_access() {
|
||||
if let Err(e) =
|
||||
apply_sandbox_policy_to_current_thread(&sandbox_policy, &sandbox_policy_cwd, false)
|
||||
{
|
||||
if sandbox_policy.has_full_disk_write_access() && !allow_network_for_proxy {
|
||||
if let Err(e) = apply_sandbox_policy_to_current_thread(
|
||||
&sandbox_policy,
|
||||
&sandbox_policy_cwd,
|
||||
false,
|
||||
allow_network_for_proxy,
|
||||
) {
|
||||
panic!("error applying Linux sandbox restrictions: {e:?}");
|
||||
}
|
||||
exec_or_panic(command);
|
||||
|
|
@ -100,15 +116,25 @@ pub fn run_main() -> ! {
|
|||
&sandbox_policy_cwd,
|
||||
&sandbox_policy,
|
||||
use_bwrap_sandbox,
|
||||
allow_network_for_proxy,
|
||||
command,
|
||||
);
|
||||
run_bwrap_with_proc_fallback(&sandbox_policy_cwd, &sandbox_policy, inner, !no_proc);
|
||||
run_bwrap_with_proc_fallback(
|
||||
&sandbox_policy_cwd,
|
||||
&sandbox_policy,
|
||||
inner,
|
||||
!no_proc,
|
||||
allow_network_for_proxy,
|
||||
);
|
||||
}
|
||||
|
||||
// Legacy path: Landlock enforcement only, when bwrap sandboxing is not enabled.
|
||||
if let Err(e) =
|
||||
apply_sandbox_policy_to_current_thread(&sandbox_policy, &sandbox_policy_cwd, true)
|
||||
{
|
||||
if let Err(e) = apply_sandbox_policy_to_current_thread(
|
||||
&sandbox_policy,
|
||||
&sandbox_policy_cwd,
|
||||
true,
|
||||
allow_network_for_proxy,
|
||||
) {
|
||||
panic!("error applying legacy Linux sandbox restrictions: {e:?}");
|
||||
}
|
||||
exec_or_panic(command);
|
||||
|
|
@ -119,6 +145,7 @@ fn run_bwrap_with_proc_fallback(
|
|||
sandbox_policy: &codex_core::protocol::SandboxPolicy,
|
||||
inner: Vec<String>,
|
||||
mount_proc: bool,
|
||||
allow_network_for_proxy: bool,
|
||||
) -> ! {
|
||||
let mut mount_proc = mount_proc;
|
||||
|
||||
|
|
@ -127,11 +154,28 @@ fn run_bwrap_with_proc_fallback(
|
|||
mount_proc = false;
|
||||
}
|
||||
|
||||
let options = BwrapOptions { mount_proc };
|
||||
let network_mode = bwrap_network_mode(sandbox_policy, allow_network_for_proxy);
|
||||
let options = BwrapOptions {
|
||||
mount_proc,
|
||||
network_mode,
|
||||
};
|
||||
let argv = build_bwrap_argv(inner, sandbox_policy, sandbox_policy_cwd, options);
|
||||
exec_vendored_bwrap(argv);
|
||||
}
|
||||
|
||||
fn bwrap_network_mode(
|
||||
sandbox_policy: &codex_core::protocol::SandboxPolicy,
|
||||
allow_network_for_proxy: bool,
|
||||
) -> BwrapNetworkMode {
|
||||
if allow_network_for_proxy {
|
||||
BwrapNetworkMode::ProxyOnly
|
||||
} else if sandbox_policy.has_full_network_access() {
|
||||
BwrapNetworkMode::FullAccess
|
||||
} else {
|
||||
BwrapNetworkMode::Isolated
|
||||
}
|
||||
}
|
||||
|
||||
fn build_bwrap_argv(
|
||||
inner: Vec<String>,
|
||||
sandbox_policy: &codex_core::protocol::SandboxPolicy,
|
||||
|
|
@ -164,7 +208,10 @@ fn preflight_proc_mount_support(
|
|||
preflight_command,
|
||||
sandbox_policy,
|
||||
sandbox_policy_cwd,
|
||||
BwrapOptions { mount_proc: true },
|
||||
BwrapOptions {
|
||||
mount_proc: true,
|
||||
network_mode: BwrapNetworkMode::FullAccess,
|
||||
},
|
||||
);
|
||||
let stderr = run_bwrap_in_child_capture_stderr(preflight_argv);
|
||||
!is_proc_mount_failure(stderr.as_str())
|
||||
|
|
@ -268,6 +315,7 @@ fn build_inner_seccomp_command(
|
|||
sandbox_policy_cwd: &Path,
|
||||
sandbox_policy: &codex_core::protocol::SandboxPolicy,
|
||||
use_bwrap_sandbox: bool,
|
||||
allow_network_for_proxy: bool,
|
||||
command: Vec<String>,
|
||||
) -> Vec<String> {
|
||||
let current_exe = match std::env::current_exe() {
|
||||
|
|
@ -290,6 +338,9 @@ fn build_inner_seccomp_command(
|
|||
inner.push("--use-bwrap-sandbox".to_string());
|
||||
inner.push("--apply-seccomp-then-exec".to_string());
|
||||
}
|
||||
if allow_network_for_proxy {
|
||||
inner.push("--allow-network-for-proxy".to_string());
|
||||
}
|
||||
inner.push("--".to_string());
|
||||
inner.extend(command);
|
||||
inner
|
||||
|
|
@ -342,7 +393,10 @@ mod tests {
|
|||
vec!["/bin/true".to_string()],
|
||||
&SandboxPolicy::ReadOnly,
|
||||
Path::new("/"),
|
||||
BwrapOptions { mount_proc: true },
|
||||
BwrapOptions {
|
||||
mount_proc: true,
|
||||
network_mode: BwrapNetworkMode::FullAccess,
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
argv,
|
||||
|
|
@ -366,4 +420,38 @@ mod tests {
|
|||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserts_unshare_net_when_network_isolation_requested() {
|
||||
let argv = build_bwrap_argv(
|
||||
vec!["/bin/true".to_string()],
|
||||
&SandboxPolicy::ReadOnly,
|
||||
Path::new("/"),
|
||||
BwrapOptions {
|
||||
mount_proc: true,
|
||||
network_mode: BwrapNetworkMode::Isolated,
|
||||
},
|
||||
);
|
||||
assert_eq!(argv.contains(&"--unshare-net".to_string()), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserts_unshare_net_when_proxy_only_network_mode_requested() {
|
||||
let argv = build_bwrap_argv(
|
||||
vec!["/bin/true".to_string()],
|
||||
&SandboxPolicy::ReadOnly,
|
||||
Path::new("/"),
|
||||
BwrapOptions {
|
||||
mount_proc: true,
|
||||
network_mode: BwrapNetworkMode::ProxyOnly,
|
||||
},
|
||||
);
|
||||
assert_eq!(argv.contains(&"--unshare-net".to_string()), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_only_mode_takes_precedence_over_full_network_policy() {
|
||||
let mode = bwrap_network_mode(&SandboxPolicy::DangerFullAccess, true);
|
||||
assert_eq!(mode, BwrapNetworkMode::ProxyOnly);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,10 +20,17 @@ pub use network_policy::NetworkPolicyDecider;
|
|||
pub use network_policy::NetworkPolicyRequest;
|
||||
pub use network_policy::NetworkPolicyRequestArgs;
|
||||
pub use network_policy::NetworkProtocol;
|
||||
pub use proxy::ALL_PROXY_ENV_KEYS;
|
||||
pub use proxy::ALLOW_LOCAL_BINDING_ENV_KEY;
|
||||
pub use proxy::Args;
|
||||
pub use proxy::DEFAULT_NO_PROXY_VALUE;
|
||||
pub use proxy::NO_PROXY_ENV_KEYS;
|
||||
pub use proxy::NetworkProxy;
|
||||
pub use proxy::NetworkProxyBuilder;
|
||||
pub use proxy::NetworkProxyHandle;
|
||||
pub use proxy::PROXY_URL_ENV_KEYS;
|
||||
pub use proxy::has_proxy_url_env_vars;
|
||||
pub use proxy::proxy_url_env_value;
|
||||
pub use runtime::ConfigReloader;
|
||||
pub use runtime::ConfigState;
|
||||
pub use runtime::NetworkProxyState;
|
||||
|
|
|
|||
|
|
@ -176,6 +176,8 @@ impl NetworkProxyBuilder {
|
|||
state,
|
||||
http_addr,
|
||||
socks_addr,
|
||||
socks_enabled: current_cfg.network.enable_socks5,
|
||||
allow_local_binding: current_cfg.network.allow_local_binding,
|
||||
admin_addr,
|
||||
reserved_listeners,
|
||||
policy_decider: self.policy_decider,
|
||||
|
|
@ -202,6 +204,8 @@ pub struct NetworkProxy {
|
|||
state: Arc<NetworkProxyState>,
|
||||
http_addr: SocketAddr,
|
||||
socks_addr: SocketAddr,
|
||||
socks_enabled: bool,
|
||||
allow_local_binding: bool,
|
||||
admin_addr: SocketAddr,
|
||||
reserved_listeners: Option<Arc<ReservedListeners>>,
|
||||
policy_decider: Option<Arc<dyn NetworkPolicyDecider>>,
|
||||
|
|
@ -223,24 +227,151 @@ impl PartialEq for NetworkProxy {
|
|||
fn eq(&self, other: &Self) -> bool {
|
||||
self.http_addr == other.http_addr
|
||||
&& self.socks_addr == other.socks_addr
|
||||
&& self.allow_local_binding == other.allow_local_binding
|
||||
&& self.admin_addr == other.admin_addr
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for NetworkProxy {}
|
||||
|
||||
pub const PROXY_URL_ENV_KEYS: &[&str] = &[
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"ALL_PROXY",
|
||||
"FTP_PROXY",
|
||||
"YARN_HTTP_PROXY",
|
||||
"YARN_HTTPS_PROXY",
|
||||
"NPM_CONFIG_HTTP_PROXY",
|
||||
"NPM_CONFIG_HTTPS_PROXY",
|
||||
"NPM_CONFIG_PROXY",
|
||||
"BUNDLE_HTTP_PROXY",
|
||||
"BUNDLE_HTTPS_PROXY",
|
||||
"PIP_PROXY",
|
||||
"DOCKER_HTTP_PROXY",
|
||||
"DOCKER_HTTPS_PROXY",
|
||||
];
|
||||
|
||||
pub const ALL_PROXY_ENV_KEYS: &[&str] = &["ALL_PROXY", "all_proxy"];
|
||||
pub const ALLOW_LOCAL_BINDING_ENV_KEY: &str = "CODEX_NETWORK_ALLOW_LOCAL_BINDING";
|
||||
|
||||
const FTP_PROXY_ENV_KEYS: &[&str] = &["FTP_PROXY", "ftp_proxy"];
|
||||
|
||||
pub const NO_PROXY_ENV_KEYS: &[&str] = &[
|
||||
"NO_PROXY",
|
||||
"no_proxy",
|
||||
"npm_config_noproxy",
|
||||
"NPM_CONFIG_NOPROXY",
|
||||
"YARN_NO_PROXY",
|
||||
"BUNDLE_NO_PROXY",
|
||||
];
|
||||
|
||||
pub const DEFAULT_NO_PROXY_VALUE: &str = concat!(
|
||||
"localhost,127.0.0.1,::1,",
|
||||
"*.local,.local,",
|
||||
"169.254.0.0/16,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
|
||||
);
|
||||
|
||||
pub fn proxy_url_env_value<'a>(
|
||||
env: &'a HashMap<String, String>,
|
||||
canonical_key: &str,
|
||||
) -> Option<&'a str> {
|
||||
if let Some(value) = env.get(canonical_key) {
|
||||
return Some(value.as_str());
|
||||
}
|
||||
let lower_key = canonical_key.to_ascii_lowercase();
|
||||
env.get(lower_key.as_str()).map(String::as_str)
|
||||
}
|
||||
|
||||
pub fn has_proxy_url_env_vars(env: &HashMap<String, String>) -> bool {
|
||||
PROXY_URL_ENV_KEYS
|
||||
.iter()
|
||||
.any(|key| proxy_url_env_value(env, key).is_some_and(|value| !value.trim().is_empty()))
|
||||
}
|
||||
|
||||
fn set_env_keys(env: &mut HashMap<String, String>, keys: &[&str], value: &str) {
|
||||
for key in keys {
|
||||
env.insert((*key).to_string(), value.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_proxy_env_overrides(
|
||||
env: &mut HashMap<String, String>,
|
||||
http_addr: SocketAddr,
|
||||
socks_addr: SocketAddr,
|
||||
socks_enabled: bool,
|
||||
allow_local_binding: bool,
|
||||
) {
|
||||
let http_proxy_url = format!("http://{http_addr}");
|
||||
let socks_proxy_url = format!("socks5h://{socks_addr}");
|
||||
env.insert(
|
||||
ALLOW_LOCAL_BINDING_ENV_KEY.to_string(),
|
||||
if allow_local_binding {
|
||||
"1".to_string()
|
||||
} else {
|
||||
"0".to_string()
|
||||
},
|
||||
);
|
||||
|
||||
// HTTP-based clients are best served by explicit HTTP proxy URLs.
|
||||
set_env_keys(
|
||||
env,
|
||||
&[
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"YARN_HTTP_PROXY",
|
||||
"YARN_HTTPS_PROXY",
|
||||
"npm_config_http_proxy",
|
||||
"npm_config_https_proxy",
|
||||
"npm_config_proxy",
|
||||
"NPM_CONFIG_HTTP_PROXY",
|
||||
"NPM_CONFIG_HTTPS_PROXY",
|
||||
"NPM_CONFIG_PROXY",
|
||||
"BUNDLE_HTTP_PROXY",
|
||||
"BUNDLE_HTTPS_PROXY",
|
||||
"PIP_PROXY",
|
||||
"DOCKER_HTTP_PROXY",
|
||||
"DOCKER_HTTPS_PROXY",
|
||||
],
|
||||
&http_proxy_url,
|
||||
);
|
||||
|
||||
// Keep local/private targets direct so local IPC and metadata endpoints avoid the proxy.
|
||||
set_env_keys(env, NO_PROXY_ENV_KEYS, DEFAULT_NO_PROXY_VALUE);
|
||||
|
||||
env.insert("ELECTRON_GET_USE_PROXY".to_string(), "true".to_string());
|
||||
|
||||
if socks_enabled {
|
||||
set_env_keys(env, ALL_PROXY_ENV_KEYS, &socks_proxy_url);
|
||||
set_env_keys(env, FTP_PROXY_ENV_KEYS, &socks_proxy_url);
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// Preserve existing SSH wrappers (for example: Secretive/Teleport setups)
|
||||
// and only provide a SOCKS ProxyCommand fallback when one is not present.
|
||||
env.entry("GIT_SSH_COMMAND".to_string())
|
||||
.or_insert_with(|| format!("ssh -o ProxyCommand='nc -X 5 -x {socks_addr} %h %p'"));
|
||||
}
|
||||
} else {
|
||||
set_env_keys(env, ALL_PROXY_ENV_KEYS, &http_proxy_url);
|
||||
}
|
||||
}
|
||||
|
||||
impl NetworkProxy {
|
||||
pub fn builder() -> NetworkProxyBuilder {
|
||||
NetworkProxyBuilder::default()
|
||||
}
|
||||
|
||||
pub fn apply_to_env(&self, env: &mut HashMap<String, String>) {
|
||||
// Enforce proxying for all child processes when configured. We always override to ensure
|
||||
// the proxy is actually used even if the caller passed conflicting environment variables.
|
||||
let proxy_url = format!("http://{}", self.http_addr);
|
||||
for key in ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"] {
|
||||
env.insert(key.to_string(), proxy_url.clone());
|
||||
}
|
||||
// Enforce proxying for child processes. We intentionally override existing values so
|
||||
// command-level environment cannot bypass the managed proxy endpoint.
|
||||
apply_proxy_env_overrides(
|
||||
env,
|
||||
self.http_addr,
|
||||
self.socks_addr,
|
||||
self.socks_enabled,
|
||||
self.allow_local_binding,
|
||||
);
|
||||
}
|
||||
|
||||
pub async fn run(&self) -> Result<NetworkProxyHandle> {
|
||||
|
|
@ -406,6 +537,9 @@ mod tests {
|
|||
use super::*;
|
||||
use crate::config::NetworkProxySettings;
|
||||
use crate::state::network_proxy_state_for_policy;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::net::IpAddr;
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
#[tokio::test]
|
||||
async fn managed_proxy_builder_uses_loopback_ephemeral_ports() {
|
||||
|
|
@ -462,4 +596,111 @@ mod tests {
|
|||
"127.0.0.1:48080".parse::<SocketAddr>().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_url_env_value_resolves_lowercase_aliases() {
|
||||
let mut env = HashMap::new();
|
||||
env.insert(
|
||||
"http_proxy".to_string(),
|
||||
"http://127.0.0.1:3128".to_string(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
proxy_url_env_value(&env, "HTTP_PROXY"),
|
||||
Some("http://127.0.0.1:3128")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_proxy_url_env_vars_detects_lowercase_aliases() {
|
||||
let mut env = HashMap::new();
|
||||
env.insert(
|
||||
"all_proxy".to_string(),
|
||||
"socks5h://127.0.0.1:8081".to_string(),
|
||||
);
|
||||
|
||||
assert_eq!(has_proxy_url_env_vars(&env), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_proxy_env_overrides_sets_common_tool_vars() {
|
||||
let mut env = HashMap::new();
|
||||
apply_proxy_env_overrides(
|
||||
&mut env,
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
|
||||
true,
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
env.get("HTTP_PROXY"),
|
||||
Some(&"http://127.0.0.1:3128".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
env.get("npm_config_proxy"),
|
||||
Some(&"http://127.0.0.1:3128".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
env.get("ALL_PROXY"),
|
||||
Some(&"socks5h://127.0.0.1:8081".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
env.get("FTP_PROXY"),
|
||||
Some(&"socks5h://127.0.0.1:8081".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
env.get("NO_PROXY"),
|
||||
Some(&DEFAULT_NO_PROXY_VALUE.to_string())
|
||||
);
|
||||
assert_eq!(env.get(ALLOW_LOCAL_BINDING_ENV_KEY), Some(&"0".to_string()));
|
||||
assert_eq!(env.get("ELECTRON_GET_USE_PROXY"), Some(&"true".to_string()));
|
||||
#[cfg(target_os = "macos")]
|
||||
assert_eq!(
|
||||
env.get("GIT_SSH_COMMAND"),
|
||||
Some(&"ssh -o ProxyCommand='nc -X 5 -x 127.0.0.1:8081 %h %p'".to_string())
|
||||
);
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
assert_eq!(env.get("GIT_SSH_COMMAND"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_proxy_env_overrides_uses_http_for_all_proxy_without_socks() {
|
||||
let mut env = HashMap::new();
|
||||
apply_proxy_env_overrides(
|
||||
&mut env,
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
|
||||
false,
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
env.get("ALL_PROXY"),
|
||||
Some(&"http://127.0.0.1:3128".to_string())
|
||||
);
|
||||
assert_eq!(env.get(ALLOW_LOCAL_BINDING_ENV_KEY), Some(&"1".to_string()));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn apply_proxy_env_overrides_preserves_existing_git_ssh_command() {
|
||||
let mut env = HashMap::new();
|
||||
env.insert(
|
||||
"GIT_SSH_COMMAND".to_string(),
|
||||
"ssh -o ProxyCommand='tsh proxy ssh --cluster=dev %r@%h:%p'".to_string(),
|
||||
);
|
||||
apply_proxy_env_overrides(
|
||||
&mut env,
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
|
||||
true,
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
env.get("GIT_SSH_COMMAND"),
|
||||
Some(&"ssh -o ProxyCommand='tsh proxy ssh --cluster=dev %r@%h:%p'".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue