feat(linux-sandbox): implement proxy-only egress via TCP-UDS-TCP bridge (#11293)
## Summary - Implement Linux proxy-only routing in `codex-rs/linux-sandbox` with a two-stage bridge: host namespace `loopback TCP proxy endpoint -> UDS`, then bwrap netns `loopback TCP listener -> host UDS`. - Add hidden `--proxy-route-spec` plumbing for outer-to-inner stage handoff. - Fail closed in proxy mode when no valid loopback proxy endpoints can be routed. - Introduce explicit network seccomp modes: `Restricted` (legacy restricted networking) and `ProxyRouted` (allow INET/INET6 for routed proxy access, deny `AF_UNIX` and `socketpair`). - Enforce that proxy bridge/routing is bwrap-only by validating `--apply-seccomp-then-exec` requires `--use-bwrap-sandbox`. - Keep landlock-only flows unchanged (no proxy bridge behavior outside bwrap). --------- Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com>
This commit is contained in:
parent
e7b6f38b58
commit
b3202cbd58
11 changed files with 1501 additions and 149 deletions
2
codex-rs/Cargo.lock
generated
2
codex-rs/Cargo.lock
generated
|
|
@ -1921,9 +1921,11 @@ dependencies = [
|
|||
"pkg-config",
|
||||
"pretty_assertions",
|
||||
"seccompiler",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -23,7 +23,9 @@ codex-utils-absolute-path = { workspace = true }
|
|||
landlock = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
seccompiler = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
url = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -28,6 +28,11 @@ into this binary.
|
|||
- 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`.
|
||||
- In managed proxy mode, the helper uses `--unshare-net` plus an internal
|
||||
TCP->UDS->TCP routing bridge so tool traffic reaches only configured proxy
|
||||
endpoints.
|
||||
- In managed proxy mode, after the bridge is live, seccomp blocks new
|
||||
AF_UNIX/socketpair creation for the user command.
|
||||
- When enabled, it mounts a fresh `/proc` via `--proc /proc` by default, but
|
||||
you can skip this in restrictive container environments with `--no-proc`.
|
||||
|
||||
|
|
|
|||
|
|
@ -49,8 +49,8 @@ pub(crate) enum BwrapNetworkMode {
|
|||
Isolated,
|
||||
/// Intended proxy-only mode.
|
||||
///
|
||||
/// Bubblewrap does not currently enforce proxy-only egress, so this is
|
||||
/// treated as isolated for fail-closed behavior.
|
||||
/// Bubblewrap enforces this by unsharing the network namespace. The
|
||||
/// proxy-routing bridge is established by the helper process after startup.
|
||||
ProxyOnly,
|
||||
}
|
||||
|
||||
|
|
@ -323,6 +323,7 @@ fn find_first_non_existent_component(target_path: &Path) -> Option<PathBuf> {
|
|||
mod tests {
|
||||
use super::*;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
|
|
@ -373,4 +374,33 @@ mod tests {
|
|||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounts_dev_before_writable_dev_binds() {
|
||||
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![AbsolutePathBuf::try_from(Path::new("/dev")).expect("/dev path")],
|
||||
read_only_access: Default::default(),
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
};
|
||||
|
||||
let args = create_filesystem_args(&sandbox_policy, Path::new("/")).expect("bwrap fs args");
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"--ro-bind".to_string(),
|
||||
"/".to_string(),
|
||||
"/".to_string(),
|
||||
"--dev".to_string(),
|
||||
"/dev".to_string(),
|
||||
"--bind".to_string(),
|
||||
"/dev".to_string(),
|
||||
"/dev".to_string(),
|
||||
"--bind".to_string(),
|
||||
"/".to_string(),
|
||||
"/".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,22 +43,26 @@ pub(crate) fn apply_sandbox_policy_to_current_thread(
|
|||
cwd: &Path,
|
||||
apply_landlock_fs: bool,
|
||||
allow_network_for_proxy: bool,
|
||||
proxy_routed_network: bool,
|
||||
) -> Result<()> {
|
||||
let install_network_seccomp =
|
||||
should_install_network_seccomp(sandbox_policy, allow_network_for_proxy);
|
||||
let network_seccomp_mode = network_seccomp_mode(
|
||||
sandbox_policy,
|
||||
allow_network_for_proxy,
|
||||
proxy_routed_network,
|
||||
);
|
||||
|
||||
// `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 install_network_seccomp
|
||||
if network_seccomp_mode.is_some()
|
||||
|| (apply_landlock_fs && !sandbox_policy.has_full_disk_write_access())
|
||||
{
|
||||
set_no_new_privs()?;
|
||||
}
|
||||
|
||||
if install_network_seccomp {
|
||||
install_network_seccomp_filter_on_current_thread()?;
|
||||
if let Some(mode) = network_seccomp_mode {
|
||||
install_network_seccomp_filter_on_current_thread(mode)?;
|
||||
}
|
||||
|
||||
if apply_landlock_fs && !sandbox_policy.has_full_disk_write_access() {
|
||||
|
|
@ -80,6 +84,12 @@ pub(crate) fn apply_sandbox_policy_to_current_thread(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum NetworkSeccompMode {
|
||||
Restricted,
|
||||
ProxyRouted,
|
||||
}
|
||||
|
||||
fn should_install_network_seccomp(
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
allow_network_for_proxy: bool,
|
||||
|
|
@ -89,6 +99,20 @@ fn should_install_network_seccomp(
|
|||
!sandbox_policy.has_full_network_access() || allow_network_for_proxy
|
||||
}
|
||||
|
||||
fn network_seccomp_mode(
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
allow_network_for_proxy: bool,
|
||||
proxy_routed_network: bool,
|
||||
) -> Option<NetworkSeccompMode> {
|
||||
if !should_install_network_seccomp(sandbox_policy, allow_network_for_proxy) {
|
||||
None
|
||||
} else if proxy_routed_network {
|
||||
Some(NetworkSeccompMode::ProxyRouted)
|
||||
} else {
|
||||
Some(NetworkSeccompMode::Restricted)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) };
|
||||
|
|
@ -135,51 +159,87 @@ fn install_filesystem_landlock_rules_on_current_thread(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Installs a seccomp filter that blocks outbound network access except for
|
||||
/// AF_UNIX domain sockets.
|
||||
/// Installs a seccomp filter for Linux network sandboxing.
|
||||
///
|
||||
/// The filter is applied to the current thread so only the sandboxed child
|
||||
/// inherits it.
|
||||
fn install_network_seccomp_filter_on_current_thread() -> std::result::Result<(), SandboxErr> {
|
||||
fn install_network_seccomp_filter_on_current_thread(
|
||||
mode: NetworkSeccompMode,
|
||||
) -> std::result::Result<(), SandboxErr> {
|
||||
fn deny_syscall(rules: &mut BTreeMap<i64, Vec<SeccompRule>>, nr: i64) {
|
||||
rules.insert(nr, vec![]); // empty rule vec = unconditional match
|
||||
}
|
||||
|
||||
// Build rule map.
|
||||
let mut rules: BTreeMap<i64, Vec<SeccompRule>> = BTreeMap::new();
|
||||
|
||||
// Helper – insert unconditional deny rule for syscall number.
|
||||
let mut deny_syscall = |nr: i64| {
|
||||
rules.insert(nr, vec![]); // empty rule vec = unconditional match
|
||||
};
|
||||
deny_syscall(&mut rules, libc::SYS_ptrace);
|
||||
deny_syscall(&mut rules, libc::SYS_io_uring_setup);
|
||||
deny_syscall(&mut rules, libc::SYS_io_uring_enter);
|
||||
deny_syscall(&mut rules, libc::SYS_io_uring_register);
|
||||
|
||||
deny_syscall(libc::SYS_connect);
|
||||
deny_syscall(libc::SYS_accept);
|
||||
deny_syscall(libc::SYS_accept4);
|
||||
deny_syscall(libc::SYS_bind);
|
||||
deny_syscall(libc::SYS_listen);
|
||||
deny_syscall(libc::SYS_getpeername);
|
||||
deny_syscall(libc::SYS_getsockname);
|
||||
deny_syscall(libc::SYS_shutdown);
|
||||
deny_syscall(libc::SYS_sendto);
|
||||
deny_syscall(libc::SYS_sendmmsg);
|
||||
// NOTE: allowing recvfrom allows some tools like: `cargo clippy` to run
|
||||
// with their socketpair + child processes for sub-proc management
|
||||
// deny_syscall(libc::SYS_recvfrom);
|
||||
deny_syscall(libc::SYS_recvmmsg);
|
||||
deny_syscall(libc::SYS_getsockopt);
|
||||
deny_syscall(libc::SYS_setsockopt);
|
||||
deny_syscall(libc::SYS_ptrace);
|
||||
deny_syscall(libc::SYS_io_uring_setup);
|
||||
deny_syscall(libc::SYS_io_uring_enter);
|
||||
deny_syscall(libc::SYS_io_uring_register);
|
||||
match mode {
|
||||
NetworkSeccompMode::Restricted => {
|
||||
deny_syscall(&mut rules, libc::SYS_connect);
|
||||
deny_syscall(&mut rules, libc::SYS_accept);
|
||||
deny_syscall(&mut rules, libc::SYS_accept4);
|
||||
deny_syscall(&mut rules, libc::SYS_bind);
|
||||
deny_syscall(&mut rules, libc::SYS_listen);
|
||||
deny_syscall(&mut rules, libc::SYS_getpeername);
|
||||
deny_syscall(&mut rules, libc::SYS_getsockname);
|
||||
deny_syscall(&mut rules, libc::SYS_shutdown);
|
||||
deny_syscall(&mut rules, libc::SYS_sendto);
|
||||
deny_syscall(&mut rules, libc::SYS_sendmmsg);
|
||||
// NOTE: allowing recvfrom allows some tools like: `cargo clippy`
|
||||
// to run with their socketpair + child processes for sub-proc
|
||||
// management.
|
||||
// deny_syscall(&mut rules, libc::SYS_recvfrom);
|
||||
deny_syscall(&mut rules, libc::SYS_recvmmsg);
|
||||
deny_syscall(&mut rules, libc::SYS_getsockopt);
|
||||
deny_syscall(&mut rules, libc::SYS_setsockopt);
|
||||
|
||||
// For `socket` we allow AF_UNIX (arg0 == AF_UNIX) and deny everything else.
|
||||
let unix_only_rule = SeccompRule::new(vec![SeccompCondition::new(
|
||||
0, // first argument (domain)
|
||||
SeccompCmpArgLen::Dword,
|
||||
SeccompCmpOp::Ne,
|
||||
libc::AF_UNIX as u64,
|
||||
)?])?;
|
||||
// For `socket` we allow AF_UNIX (arg0 == AF_UNIX) and deny
|
||||
// everything else.
|
||||
let unix_only_rule = SeccompRule::new(vec![SeccompCondition::new(
|
||||
0, // first argument (domain)
|
||||
SeccompCmpArgLen::Dword,
|
||||
SeccompCmpOp::Ne,
|
||||
libc::AF_UNIX as u64,
|
||||
)?])?;
|
||||
|
||||
rules.insert(libc::SYS_socket, vec![unix_only_rule.clone()]);
|
||||
rules.insert(libc::SYS_socketpair, vec![unix_only_rule]); // always deny (Unix can use socketpair but fine, keep open?)
|
||||
rules.insert(libc::SYS_socket, vec![unix_only_rule.clone()]);
|
||||
rules.insert(libc::SYS_socketpair, vec![unix_only_rule]);
|
||||
}
|
||||
NetworkSeccompMode::ProxyRouted => {
|
||||
// In proxy-routed mode we allow IP sockets in the isolated
|
||||
// namespace (used to reach the local TCP bridge) but deny all
|
||||
// other socket families, including AF_UNIX. This prevents
|
||||
// bypassing the routed bridge via new Unix sockets and narrows the
|
||||
// socket surface in proxy-only mode.
|
||||
let deny_non_ip_socket = SeccompRule::new(vec![
|
||||
SeccompCondition::new(
|
||||
0,
|
||||
SeccompCmpArgLen::Dword,
|
||||
SeccompCmpOp::Ne,
|
||||
libc::AF_INET as u64,
|
||||
)?,
|
||||
SeccompCondition::new(
|
||||
0,
|
||||
SeccompCmpArgLen::Dword,
|
||||
SeccompCmpOp::Ne,
|
||||
libc::AF_INET6 as u64,
|
||||
)?,
|
||||
])?;
|
||||
let deny_unix_socketpair = SeccompRule::new(vec![SeccompCondition::new(
|
||||
0,
|
||||
SeccompCmpArgLen::Dword,
|
||||
SeccompCmpOp::Eq,
|
||||
libc::AF_UNIX as u64,
|
||||
)?])?;
|
||||
rules.insert(libc::SYS_socket, vec![deny_non_ip_socket]);
|
||||
rules.insert(libc::SYS_socketpair, vec![deny_unix_socketpair]);
|
||||
}
|
||||
}
|
||||
|
||||
let filter = SeccompFilter::new(
|
||||
rules,
|
||||
|
|
@ -203,6 +263,8 @@ fn install_network_seccomp_filter_on_current_thread() -> std::result::Result<(),
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::NetworkSeccompMode;
|
||||
use super::network_seccomp_mode;
|
||||
use super::should_install_network_seccomp;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
|
@ -234,4 +296,28 @@ mod tests {
|
|||
true
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_proxy_routes_use_proxy_routed_seccomp_mode() {
|
||||
assert_eq!(
|
||||
network_seccomp_mode(&SandboxPolicy::DangerFullAccess, true, true),
|
||||
Some(NetworkSeccompMode::ProxyRouted)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restricted_network_without_proxy_routing_uses_restricted_mode() {
|
||||
assert_eq!(
|
||||
network_seccomp_mode(&SandboxPolicy::new_read_only_policy(), false, false),
|
||||
Some(NetworkSeccompMode::Restricted)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_network_without_managed_proxy_skips_network_seccomp_mode() {
|
||||
assert_eq!(
|
||||
network_seccomp_mode(&SandboxPolicy::DangerFullAccess, false, false),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ mod landlock;
|
|||
#[cfg(target_os = "linux")]
|
||||
mod linux_run_main;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod proxy_routing;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod vendored_bwrap;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ use crate::bwrap::BwrapNetworkMode;
|
|||
use crate::bwrap::BwrapOptions;
|
||||
use crate::bwrap::create_bwrap_command_args;
|
||||
use crate::landlock::apply_sandbox_policy_to_current_thread;
|
||||
use crate::proxy_routing::activate_proxy_routes_in_netns;
|
||||
use crate::proxy_routing::prepare_host_proxy_route_spec;
|
||||
use crate::vendored_bwrap::exec_vendored_bwrap;
|
||||
use crate::vendored_bwrap::run_vendored_bwrap_main;
|
||||
|
||||
|
|
@ -44,11 +46,15 @@ pub struct LandlockCommand {
|
|||
/// 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).
|
||||
/// If set, sandbox setup switches to proxy-only network mode with
|
||||
/// managed routing bridges.
|
||||
#[arg(long = "allow-network-for-proxy", hide = true, default_value_t = false)]
|
||||
pub allow_network_for_proxy: bool,
|
||||
|
||||
/// Internal route spec used for managed proxy routing in bwrap mode.
|
||||
#[arg(long = "proxy-route-spec", hide = true)]
|
||||
pub proxy_route_spec: Option<String>,
|
||||
|
||||
/// 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`.
|
||||
|
|
@ -74,6 +80,7 @@ pub fn run_main() -> ! {
|
|||
use_bwrap_sandbox,
|
||||
apply_seccomp_then_exec,
|
||||
allow_network_for_proxy,
|
||||
proxy_route_spec,
|
||||
no_proc,
|
||||
command,
|
||||
} = LandlockCommand::parse();
|
||||
|
|
@ -81,15 +88,26 @@ pub fn run_main() -> ! {
|
|||
if command.is_empty() {
|
||||
panic!("No command specified to execute.");
|
||||
}
|
||||
ensure_inner_stage_mode_is_valid(apply_seccomp_then_exec, use_bwrap_sandbox);
|
||||
|
||||
// Inner stage: apply seccomp/no_new_privs after bubblewrap has already
|
||||
// established the filesystem view.
|
||||
if apply_seccomp_then_exec {
|
||||
if allow_network_for_proxy {
|
||||
let spec = proxy_route_spec
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| panic!("managed proxy mode requires --proxy-route-spec"));
|
||||
if let Err(err) = activate_proxy_routes_in_netns(spec) {
|
||||
panic!("error activating Linux proxy routing bridge: {err}");
|
||||
}
|
||||
}
|
||||
let proxy_routing_active = allow_network_for_proxy;
|
||||
if let Err(e) = apply_sandbox_policy_to_current_thread(
|
||||
&sandbox_policy,
|
||||
&sandbox_policy_cwd,
|
||||
false,
|
||||
allow_network_for_proxy,
|
||||
proxy_routing_active,
|
||||
) {
|
||||
panic!("error applying Linux sandbox restrictions: {e:?}");
|
||||
}
|
||||
|
|
@ -102,6 +120,7 @@ pub fn run_main() -> ! {
|
|||
&sandbox_policy_cwd,
|
||||
false,
|
||||
allow_network_for_proxy,
|
||||
false,
|
||||
) {
|
||||
panic!("error applying Linux sandbox restrictions: {e:?}");
|
||||
}
|
||||
|
|
@ -112,11 +131,20 @@ pub fn run_main() -> ! {
|
|||
// Outer stage: bubblewrap first, then re-enter this binary in the
|
||||
// sandboxed environment to apply seccomp. This path never falls back
|
||||
// to legacy Landlock on failure.
|
||||
let proxy_route_spec =
|
||||
if allow_network_for_proxy {
|
||||
Some(prepare_host_proxy_route_spec().unwrap_or_else(|err| {
|
||||
panic!("failed to prepare host proxy routing bridge: {err}")
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let inner = build_inner_seccomp_command(
|
||||
&sandbox_policy_cwd,
|
||||
&sandbox_policy,
|
||||
use_bwrap_sandbox,
|
||||
allow_network_for_proxy,
|
||||
proxy_route_spec,
|
||||
command,
|
||||
);
|
||||
run_bwrap_with_proc_fallback(
|
||||
|
|
@ -134,12 +162,19 @@ pub fn run_main() -> ! {
|
|||
&sandbox_policy_cwd,
|
||||
true,
|
||||
allow_network_for_proxy,
|
||||
false,
|
||||
) {
|
||||
panic!("error applying legacy Linux sandbox restrictions: {e:?}");
|
||||
}
|
||||
exec_or_panic(command);
|
||||
}
|
||||
|
||||
fn ensure_inner_stage_mode_is_valid(apply_seccomp_then_exec: bool, use_bwrap_sandbox: bool) {
|
||||
if apply_seccomp_then_exec && !use_bwrap_sandbox {
|
||||
panic!("--apply-seccomp-then-exec requires --use-bwrap-sandbox");
|
||||
}
|
||||
}
|
||||
|
||||
fn run_bwrap_with_proc_fallback(
|
||||
sandbox_policy_cwd: &Path,
|
||||
sandbox_policy: &codex_protocol::protocol::SandboxPolicy,
|
||||
|
|
@ -147,14 +182,15 @@ fn run_bwrap_with_proc_fallback(
|
|||
mount_proc: bool,
|
||||
allow_network_for_proxy: bool,
|
||||
) -> ! {
|
||||
let network_mode = bwrap_network_mode(sandbox_policy, allow_network_for_proxy);
|
||||
let mut mount_proc = mount_proc;
|
||||
|
||||
if mount_proc && !preflight_proc_mount_support(sandbox_policy_cwd, sandbox_policy) {
|
||||
if mount_proc && !preflight_proc_mount_support(sandbox_policy_cwd, sandbox_policy, network_mode)
|
||||
{
|
||||
eprintln!("codex-linux-sandbox: bwrap could not mount /proc; retrying with --no-proc");
|
||||
mount_proc = false;
|
||||
}
|
||||
|
||||
let network_mode = bwrap_network_mode(sandbox_policy, allow_network_for_proxy);
|
||||
let options = BwrapOptions {
|
||||
mount_proc,
|
||||
network_mode,
|
||||
|
|
@ -202,19 +238,29 @@ fn build_bwrap_argv(
|
|||
fn preflight_proc_mount_support(
|
||||
sandbox_policy_cwd: &Path,
|
||||
sandbox_policy: &codex_protocol::protocol::SandboxPolicy,
|
||||
network_mode: BwrapNetworkMode,
|
||||
) -> bool {
|
||||
let preflight_argv =
|
||||
build_preflight_bwrap_argv(sandbox_policy_cwd, sandbox_policy, network_mode);
|
||||
let stderr = run_bwrap_in_child_capture_stderr(preflight_argv);
|
||||
!is_proc_mount_failure(stderr.as_str())
|
||||
}
|
||||
|
||||
fn build_preflight_bwrap_argv(
|
||||
sandbox_policy_cwd: &Path,
|
||||
sandbox_policy: &codex_protocol::protocol::SandboxPolicy,
|
||||
network_mode: BwrapNetworkMode,
|
||||
) -> Vec<String> {
|
||||
let preflight_command = vec![resolve_true_command()];
|
||||
let preflight_argv = build_bwrap_argv(
|
||||
build_bwrap_argv(
|
||||
preflight_command,
|
||||
sandbox_policy,
|
||||
sandbox_policy_cwd,
|
||||
BwrapOptions {
|
||||
mount_proc: true,
|
||||
network_mode: BwrapNetworkMode::FullAccess,
|
||||
network_mode,
|
||||
},
|
||||
);
|
||||
let stderr = run_bwrap_in_child_capture_stderr(preflight_argv);
|
||||
!is_proc_mount_failure(stderr.as_str())
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_true_command() -> String {
|
||||
|
|
@ -318,6 +364,7 @@ fn build_inner_seccomp_command(
|
|||
sandbox_policy: &codex_protocol::protocol::SandboxPolicy,
|
||||
use_bwrap_sandbox: bool,
|
||||
allow_network_for_proxy: bool,
|
||||
proxy_route_spec: Option<String>,
|
||||
command: Vec<String>,
|
||||
) -> Vec<String> {
|
||||
let current_exe = match std::env::current_exe() {
|
||||
|
|
@ -342,6 +389,10 @@ fn build_inner_seccomp_command(
|
|||
}
|
||||
if allow_network_for_proxy {
|
||||
inner.push("--allow-network-for-proxy".to_string());
|
||||
let proxy_route_spec = proxy_route_spec
|
||||
.unwrap_or_else(|| panic!("managed proxy mode requires a proxy route spec"));
|
||||
inner.push("--proxy-route-spec".to_string());
|
||||
inner.push(proxy_route_spec);
|
||||
}
|
||||
inner.push("--".to_string());
|
||||
inner.extend(command);
|
||||
|
|
@ -371,100 +422,5 @@ fn exec_or_panic(command: Vec<String>) -> ! {
|
|||
panic!("Failed to execvp {}: {err}", command[0].as_str());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn detects_proc_mount_invalid_argument_failure() {
|
||||
let stderr = "bwrap: Can't mount proc on /newroot/proc: Invalid argument";
|
||||
assert_eq!(is_proc_mount_failure(stderr), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_proc_mount_operation_not_permitted_failure() {
|
||||
let stderr = "bwrap: Can't mount proc on /newroot/proc: Operation not permitted";
|
||||
assert_eq!(is_proc_mount_failure(stderr), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_proc_mount_permission_denied_failure() {
|
||||
let stderr = "bwrap: Can't mount proc on /newroot/proc: Permission denied";
|
||||
assert_eq!(is_proc_mount_failure(stderr), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_proc_mount_errors() {
|
||||
let stderr = "bwrap: Can't bind mount /dev/null: Operation not permitted";
|
||||
assert_eq!(is_proc_mount_failure(stderr), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserts_bwrap_argv0_before_command_separator() {
|
||||
let argv = build_bwrap_argv(
|
||||
vec!["/bin/true".to_string()],
|
||||
&SandboxPolicy::new_read_only_policy(),
|
||||
Path::new("/"),
|
||||
BwrapOptions {
|
||||
mount_proc: true,
|
||||
network_mode: BwrapNetworkMode::FullAccess,
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
argv,
|
||||
vec![
|
||||
"bwrap".to_string(),
|
||||
"--new-session".to_string(),
|
||||
"--die-with-parent".to_string(),
|
||||
"--ro-bind".to_string(),
|
||||
"/".to_string(),
|
||||
"/".to_string(),
|
||||
"--dev".to_string(),
|
||||
"/dev".to_string(),
|
||||
"--unshare-pid".to_string(),
|
||||
"--proc".to_string(),
|
||||
"/proc".to_string(),
|
||||
"--argv0".to_string(),
|
||||
"codex-linux-sandbox".to_string(),
|
||||
"--".to_string(),
|
||||
"/bin/true".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserts_unshare_net_when_network_isolation_requested() {
|
||||
let argv = build_bwrap_argv(
|
||||
vec!["/bin/true".to_string()],
|
||||
&SandboxPolicy::new_read_only_policy(),
|
||||
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::new_read_only_policy(),
|
||||
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);
|
||||
}
|
||||
}
|
||||
#[path = "linux_run_main_tests.rs"]
|
||||
mod tests;
|
||||
|
|
|
|||
159
codex-rs/linux-sandbox/src/linux_run_main_tests.rs
Normal file
159
codex-rs/linux-sandbox/src/linux_run_main_tests.rs
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
#[cfg(test)]
|
||||
use super::*;
|
||||
#[cfg(test)]
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
|
||||
#[test]
|
||||
fn detects_proc_mount_invalid_argument_failure() {
|
||||
let stderr = "bwrap: Can't mount proc on /newroot/proc: Invalid argument";
|
||||
assert!(is_proc_mount_failure(stderr));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_proc_mount_operation_not_permitted_failure() {
|
||||
let stderr = "bwrap: Can't mount proc on /newroot/proc: Operation not permitted";
|
||||
assert!(is_proc_mount_failure(stderr));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_proc_mount_permission_denied_failure() {
|
||||
let stderr = "bwrap: Can't mount proc on /newroot/proc: Permission denied";
|
||||
assert!(is_proc_mount_failure(stderr));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_proc_mount_errors() {
|
||||
let stderr = "bwrap: Can't bind mount /dev/null: Operation not permitted";
|
||||
assert!(!is_proc_mount_failure(stderr));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserts_bwrap_argv0_before_command_separator() {
|
||||
let argv = build_bwrap_argv(
|
||||
vec!["/bin/true".to_string()],
|
||||
&SandboxPolicy::new_read_only_policy(),
|
||||
Path::new("/"),
|
||||
BwrapOptions {
|
||||
mount_proc: true,
|
||||
network_mode: BwrapNetworkMode::FullAccess,
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
argv,
|
||||
vec![
|
||||
"bwrap".to_string(),
|
||||
"--new-session".to_string(),
|
||||
"--die-with-parent".to_string(),
|
||||
"--ro-bind".to_string(),
|
||||
"/".to_string(),
|
||||
"/".to_string(),
|
||||
"--dev".to_string(),
|
||||
"/dev".to_string(),
|
||||
"--unshare-pid".to_string(),
|
||||
"--proc".to_string(),
|
||||
"/proc".to_string(),
|
||||
"--argv0".to_string(),
|
||||
"codex-linux-sandbox".to_string(),
|
||||
"--".to_string(),
|
||||
"/bin/true".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserts_unshare_net_when_network_isolation_requested() {
|
||||
let argv = build_bwrap_argv(
|
||||
vec!["/bin/true".to_string()],
|
||||
&SandboxPolicy::new_read_only_policy(),
|
||||
Path::new("/"),
|
||||
BwrapOptions {
|
||||
mount_proc: true,
|
||||
network_mode: BwrapNetworkMode::Isolated,
|
||||
},
|
||||
);
|
||||
assert!(argv.contains(&"--unshare-net".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserts_unshare_net_when_proxy_only_network_mode_requested() {
|
||||
let argv = build_bwrap_argv(
|
||||
vec!["/bin/true".to_string()],
|
||||
&SandboxPolicy::new_read_only_policy(),
|
||||
Path::new("/"),
|
||||
BwrapOptions {
|
||||
mount_proc: true,
|
||||
network_mode: BwrapNetworkMode::ProxyOnly,
|
||||
},
|
||||
);
|
||||
assert!(argv.contains(&"--unshare-net".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_only_mode_takes_precedence_over_full_network_policy() {
|
||||
let mode = bwrap_network_mode(&SandboxPolicy::DangerFullAccess, true);
|
||||
assert_eq!(mode, BwrapNetworkMode::ProxyOnly);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_proxy_preflight_argv_is_wrapped_for_full_access_policy() {
|
||||
let mode = bwrap_network_mode(&SandboxPolicy::DangerFullAccess, true);
|
||||
let argv = build_preflight_bwrap_argv(Path::new("/"), &SandboxPolicy::DangerFullAccess, mode);
|
||||
assert!(argv.iter().any(|arg| arg == "--"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_proxy_inner_command_includes_route_spec() {
|
||||
let args = build_inner_seccomp_command(
|
||||
Path::new("/tmp"),
|
||||
&SandboxPolicy::new_read_only_policy(),
|
||||
true,
|
||||
true,
|
||||
Some("{\"routes\":[]}".to_string()),
|
||||
vec!["/bin/true".to_string()],
|
||||
);
|
||||
|
||||
assert!(args.iter().any(|arg| arg == "--proxy-route-spec"));
|
||||
assert!(args.iter().any(|arg| arg == "{\"routes\":[]}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_managed_inner_command_omits_route_spec() {
|
||||
let args = build_inner_seccomp_command(
|
||||
Path::new("/tmp"),
|
||||
&SandboxPolicy::new_read_only_policy(),
|
||||
true,
|
||||
false,
|
||||
None,
|
||||
vec!["/bin/true".to_string()],
|
||||
);
|
||||
|
||||
assert!(!args.iter().any(|arg| arg == "--proxy-route-spec"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_proxy_inner_command_requires_route_spec() {
|
||||
let result = std::panic::catch_unwind(|| {
|
||||
build_inner_seccomp_command(
|
||||
Path::new("/tmp"),
|
||||
&SandboxPolicy::new_read_only_policy(),
|
||||
true,
|
||||
true,
|
||||
None,
|
||||
vec!["/bin/true".to_string()],
|
||||
)
|
||||
});
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_seccomp_then_exec_without_bwrap_panics() {
|
||||
let result = std::panic::catch_unwind(|| ensure_inner_stage_mode_is_valid(true, false));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_inner_stage_modes_do_not_panic() {
|
||||
ensure_inner_stage_mode_is_valid(false, false);
|
||||
ensure_inner_stage_mode_is_valid(false, true);
|
||||
ensure_inner_stage_mode_is_valid(true, true);
|
||||
}
|
||||
796
codex-rs/linux-sandbox/src/proxy_routing.rs
Normal file
796
codex-rs/linux-sandbox/src/proxy_routing.rs
Normal file
|
|
@ -0,0 +1,796 @@
|
|||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::fs::DirBuilder;
|
||||
use std::fs::File;
|
||||
use std::fs::Permissions;
|
||||
use std::io;
|
||||
use std::io::Read;
|
||||
use std::io::Write;
|
||||
use std::net::IpAddr;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::net::SocketAddr;
|
||||
use std::net::TcpListener;
|
||||
use std::net::TcpStream;
|
||||
use std::os::fd::FromRawFd;
|
||||
use std::os::unix::fs::DirBuilderExt;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::os::unix::net::UnixListener;
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
const PROXY_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",
|
||||
];
|
||||
|
||||
const PROXY_SOCKET_DIR_PREFIX: &str = "codex-linux-sandbox-proxy-";
|
||||
const HOST_BRIDGE_READY: u8 = 1;
|
||||
const LOOPBACK_INTERFACE_NAME: &[u8] = b"lo";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub(crate) struct ProxyRouteSpec {
|
||||
routes: Vec<ProxyRouteEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
struct ProxyRouteEntry {
|
||||
env_key: String,
|
||||
uds_path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct PlannedProxyRoute {
|
||||
env_key: String,
|
||||
endpoint: SocketAddr,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ProxyRoutePlan {
|
||||
routes: Vec<PlannedProxyRoute>,
|
||||
has_proxy_config: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_host_proxy_route_spec() -> io::Result<String> {
|
||||
let env: HashMap<String, String> = std::env::vars().collect();
|
||||
let plan = plan_proxy_routes(&env);
|
||||
|
||||
if plan.routes.is_empty() {
|
||||
let message = if plan.has_proxy_config {
|
||||
"managed proxy mode requires parseable loopback proxy endpoints"
|
||||
} else {
|
||||
"managed proxy mode requires proxy environment variables"
|
||||
};
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, message));
|
||||
}
|
||||
|
||||
let socket_parent_dir = proxy_socket_parent_dir();
|
||||
let _ = cleanup_stale_proxy_socket_dirs_in(socket_parent_dir.as_path());
|
||||
|
||||
let socket_dir = create_proxy_socket_dir()?;
|
||||
let mut socket_by_endpoint: BTreeMap<SocketAddr, PathBuf> = BTreeMap::new();
|
||||
let mut next_index = 0usize;
|
||||
for route in &plan.routes {
|
||||
if socket_by_endpoint.contains_key(&route.endpoint) {
|
||||
continue;
|
||||
}
|
||||
let socket_path = socket_dir.join(format!("proxy-route-{next_index}.sock"));
|
||||
next_index += 1;
|
||||
socket_by_endpoint.insert(route.endpoint, socket_path);
|
||||
}
|
||||
|
||||
let mut host_bridge_pids = Vec::with_capacity(socket_by_endpoint.len());
|
||||
for (endpoint, socket_path) in &socket_by_endpoint {
|
||||
host_bridge_pids.push(spawn_host_bridge(*endpoint, socket_path)?);
|
||||
}
|
||||
spawn_proxy_socket_dir_cleanup_worker(socket_dir, host_bridge_pids)?;
|
||||
|
||||
let mut routes = Vec::with_capacity(plan.routes.len());
|
||||
for route in plan.routes {
|
||||
let Some(uds_path) = socket_by_endpoint.get(&route.endpoint) else {
|
||||
return Err(io::Error::other(format!(
|
||||
"missing UDS path for endpoint {}",
|
||||
route.endpoint
|
||||
)));
|
||||
};
|
||||
routes.push(ProxyRouteEntry {
|
||||
env_key: route.env_key,
|
||||
uds_path: uds_path.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
serde_json::to_string(&ProxyRouteSpec { routes }).map_err(io::Error::other)
|
||||
}
|
||||
|
||||
pub(crate) fn activate_proxy_routes_in_netns(serialized_spec: &str) -> io::Result<()> {
|
||||
let spec: ProxyRouteSpec = serde_json::from_str(serialized_spec).map_err(io::Error::other)?;
|
||||
|
||||
if spec.routes.is_empty() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"proxy routing spec contained no routes",
|
||||
));
|
||||
}
|
||||
|
||||
let mut local_port_by_uds_path: BTreeMap<PathBuf, u16> = BTreeMap::new();
|
||||
for route in &spec.routes {
|
||||
if local_port_by_uds_path.contains_key(&route.uds_path) {
|
||||
continue;
|
||||
}
|
||||
let local_port = spawn_local_bridge(route.uds_path.as_path())?;
|
||||
local_port_by_uds_path.insert(route.uds_path.clone(), local_port);
|
||||
}
|
||||
|
||||
for route in spec.routes {
|
||||
let Some(local_port) = local_port_by_uds_path.get(&route.uds_path) else {
|
||||
return Err(io::Error::other(format!(
|
||||
"missing local bridge port for UDS path {}",
|
||||
route.uds_path.display()
|
||||
)));
|
||||
};
|
||||
let original_value = std::env::var(&route.env_key).map_err(|_| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
format!("missing proxy env key {}", route.env_key),
|
||||
)
|
||||
})?;
|
||||
let Some(rewritten) = rewrite_proxy_env_value(&original_value, *local_port) else {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("could not rewrite proxy URL for env key {}", route.env_key),
|
||||
));
|
||||
};
|
||||
// SAFETY: this helper process is single-threaded at this point, and
|
||||
// env mutation happens before execing the user command.
|
||||
unsafe {
|
||||
std::env::set_var(route.env_key, rewritten);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn plan_proxy_routes(env: &HashMap<String, String>) -> ProxyRoutePlan {
|
||||
let mut routes = Vec::new();
|
||||
let mut has_proxy_config = false;
|
||||
|
||||
for (key, value) in env {
|
||||
if !is_proxy_env_key(key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
has_proxy_config = true;
|
||||
|
||||
let Some(endpoint) = parse_loopback_proxy_endpoint(trimmed) else {
|
||||
continue;
|
||||
};
|
||||
routes.push(PlannedProxyRoute {
|
||||
env_key: key.clone(),
|
||||
endpoint,
|
||||
});
|
||||
}
|
||||
|
||||
routes.sort_by(|left, right| left.env_key.cmp(&right.env_key));
|
||||
ProxyRoutePlan {
|
||||
routes,
|
||||
has_proxy_config,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_proxy_env_key(key: &str) -> bool {
|
||||
let upper = key.to_ascii_uppercase();
|
||||
PROXY_ENV_KEYS.contains(&upper.as_str())
|
||||
}
|
||||
|
||||
fn parse_loopback_proxy_endpoint(proxy_url: &str) -> Option<SocketAddr> {
|
||||
let candidate = if proxy_url.contains("://") {
|
||||
proxy_url.to_string()
|
||||
} else {
|
||||
format!("http://{proxy_url}")
|
||||
};
|
||||
|
||||
let parsed = Url::parse(&candidate).ok()?;
|
||||
let host = parsed.host_str()?;
|
||||
if !is_loopback_host(host) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let scheme = parsed.scheme().to_ascii_lowercase();
|
||||
let port = parsed
|
||||
.port()
|
||||
.unwrap_or_else(|| default_proxy_port(scheme.as_str()));
|
||||
if port == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let ip = if host.eq_ignore_ascii_case("localhost") {
|
||||
IpAddr::V4(Ipv4Addr::LOCALHOST)
|
||||
} else {
|
||||
host.parse::<IpAddr>().ok()?
|
||||
};
|
||||
if ip.is_loopback() {
|
||||
Some(SocketAddr::new(ip, port))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn is_loopback_host(host: &str) -> bool {
|
||||
host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1"
|
||||
}
|
||||
|
||||
fn default_proxy_port(scheme: &str) -> u16 {
|
||||
match scheme {
|
||||
"https" => 443,
|
||||
"socks5" | "socks5h" | "socks4" | "socks4a" => 1080,
|
||||
_ => 80,
|
||||
}
|
||||
}
|
||||
|
||||
fn rewrite_proxy_env_value(proxy_url: &str, local_port: u16) -> Option<String> {
|
||||
let had_scheme = proxy_url.contains("://");
|
||||
let candidate = if had_scheme {
|
||||
proxy_url.to_string()
|
||||
} else {
|
||||
format!("http://{proxy_url}")
|
||||
};
|
||||
|
||||
let mut parsed = Url::parse(&candidate).ok()?;
|
||||
parsed.set_host(Some("127.0.0.1")).ok()?;
|
||||
parsed.set_port(Some(local_port)).ok()?;
|
||||
let mut rewritten = parsed.to_string();
|
||||
if !had_scheme {
|
||||
rewritten = rewritten
|
||||
.strip_prefix("http://")
|
||||
.unwrap_or(rewritten.as_str())
|
||||
.to_string();
|
||||
}
|
||||
if !proxy_url.ends_with('/')
|
||||
&& !proxy_url.contains('?')
|
||||
&& !proxy_url.contains('#')
|
||||
&& rewritten.ends_with('/')
|
||||
{
|
||||
rewritten.pop();
|
||||
}
|
||||
Some(rewritten)
|
||||
}
|
||||
|
||||
fn create_proxy_socket_dir() -> io::Result<PathBuf> {
|
||||
let temp_dir = proxy_socket_parent_dir();
|
||||
let pid = std::process::id();
|
||||
for attempt in 0..128 {
|
||||
let candidate = temp_dir.join(format!("{PROXY_SOCKET_DIR_PREFIX}{pid}-{attempt}"));
|
||||
// The bridge UDS paths live under a shared temp root, so the per-run
|
||||
// directory should not be traversable by other processes.
|
||||
let mut dir_builder = DirBuilder::new();
|
||||
dir_builder.mode(0o700);
|
||||
match dir_builder.create(&candidate) {
|
||||
Ok(()) => return Ok(candidate),
|
||||
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
format!(
|
||||
"failed to allocate proxy routing temp dir under {}",
|
||||
temp_dir.display()
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
fn proxy_socket_parent_dir() -> PathBuf {
|
||||
if let Some(codex_home) = std::env::var_os("CODEX_HOME") {
|
||||
let candidate = PathBuf::from(codex_home).join("tmp");
|
||||
if ensure_private_proxy_socket_parent_dir(candidate.as_path()).is_ok() {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
std::env::temp_dir()
|
||||
}
|
||||
|
||||
fn ensure_private_proxy_socket_parent_dir(path: &Path) -> io::Result<()> {
|
||||
let mut dir_builder = DirBuilder::new();
|
||||
dir_builder.recursive(true);
|
||||
dir_builder.mode(0o700);
|
||||
match dir_builder.create(path) {
|
||||
Ok(()) => {}
|
||||
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
std::fs::set_permissions(path, Permissions::from_mode(0o700))
|
||||
}
|
||||
|
||||
fn cleanup_stale_proxy_socket_dirs_in(temp_dir: &Path) -> io::Result<()> {
|
||||
for entry in std::fs::read_dir(temp_dir)? {
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let file_type = match entry.file_type() {
|
||||
Ok(file_type) => file_type,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if !file_type.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let file_name = entry.file_name();
|
||||
let file_name = file_name.to_string_lossy();
|
||||
let Some(owner_pid) = parse_proxy_socket_dir_owner_pid(file_name.as_ref()) else {
|
||||
continue;
|
||||
};
|
||||
if is_pid_alive(owner_pid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let _ = cleanup_proxy_socket_dir(entry.path().as_path());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_proxy_socket_dir_owner_pid(file_name: &str) -> Option<u32> {
|
||||
let suffix = file_name.strip_prefix(PROXY_SOCKET_DIR_PREFIX)?;
|
||||
let (pid_raw, _) = suffix.split_once('-')?;
|
||||
pid_raw.parse::<u32>().ok().filter(|pid| *pid != 0)
|
||||
}
|
||||
|
||||
fn is_pid_alive(pid: u32) -> bool {
|
||||
let Ok(pid) = libc::pid_t::try_from(pid) else {
|
||||
return false;
|
||||
};
|
||||
is_pid_alive_raw(pid)
|
||||
}
|
||||
|
||||
fn is_pid_alive_raw(pid: libc::pid_t) -> bool {
|
||||
let status = unsafe { libc::kill(pid, 0) };
|
||||
if status == 0 {
|
||||
return true;
|
||||
}
|
||||
let err = io::Error::last_os_error();
|
||||
!matches!(err.raw_os_error(), Some(libc::ESRCH))
|
||||
}
|
||||
|
||||
fn spawn_proxy_socket_dir_cleanup_worker(
|
||||
socket_dir: PathBuf,
|
||||
host_bridge_pids: Vec<libc::pid_t>,
|
||||
) -> io::Result<()> {
|
||||
let pid = unsafe { libc::fork() };
|
||||
if pid < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
|
||||
if pid == 0 {
|
||||
loop {
|
||||
if host_bridge_pids
|
||||
.iter()
|
||||
.copied()
|
||||
.all(|bridge_pid| !is_pid_alive_raw(bridge_pid))
|
||||
{
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
|
||||
let _ = cleanup_proxy_socket_dir(socket_dir.as_path());
|
||||
unsafe { libc::_exit(0) };
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cleanup_proxy_socket_dir(socket_dir: &Path) -> io::Result<()> {
|
||||
for _ in 0..20 {
|
||||
match std::fs::remove_dir_all(socket_dir) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(_) => std::thread::sleep(Duration::from_millis(100)),
|
||||
}
|
||||
}
|
||||
|
||||
match std::fs::remove_dir_all(socket_dir) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_host_bridge(endpoint: SocketAddr, uds_path: &Path) -> io::Result<libc::pid_t> {
|
||||
let (read_fd, write_fd) = create_ready_pipe()?;
|
||||
let pid = unsafe { libc::fork() };
|
||||
if pid < 0 {
|
||||
let err = io::Error::last_os_error();
|
||||
close_fd(read_fd)?;
|
||||
close_fd(write_fd)?;
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
if pid == 0 {
|
||||
if close_fd(read_fd).is_err() {
|
||||
unsafe { libc::_exit(1) };
|
||||
}
|
||||
let result = run_host_bridge(endpoint, uds_path, write_fd);
|
||||
if result.is_err() {
|
||||
unsafe { libc::_exit(1) };
|
||||
}
|
||||
unsafe { libc::_exit(0) };
|
||||
}
|
||||
|
||||
close_fd(write_fd)?;
|
||||
let mut ready = [0_u8; 1];
|
||||
let mut read_file = unsafe { File::from_raw_fd(read_fd) };
|
||||
read_file.read_exact(&mut ready)?;
|
||||
if ready[0] != HOST_BRIDGE_READY {
|
||||
return Err(io::Error::other(
|
||||
"host bridge did not acknowledge readiness",
|
||||
));
|
||||
}
|
||||
Ok(pid)
|
||||
}
|
||||
|
||||
fn run_host_bridge(endpoint: SocketAddr, uds_path: &Path, ready_fd: libc::c_int) -> io::Result<()> {
|
||||
set_parent_death_signal()?;
|
||||
if uds_path.exists() {
|
||||
std::fs::remove_file(uds_path)?;
|
||||
}
|
||||
let listener = UnixListener::bind(uds_path)?;
|
||||
|
||||
let mut ready_file = unsafe { File::from_raw_fd(ready_fd) };
|
||||
ready_file.write_all(&[HOST_BRIDGE_READY])?;
|
||||
drop(ready_file);
|
||||
|
||||
loop {
|
||||
let (unix_stream, _) = listener.accept()?;
|
||||
std::thread::spawn(move || {
|
||||
let tcp_stream = match TcpStream::connect(endpoint) {
|
||||
Ok(stream) => stream,
|
||||
Err(_) => return,
|
||||
};
|
||||
let _ = proxy_bidirectional(tcp_stream, unix_stream);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_local_bridge(uds_path: &Path) -> io::Result<u16> {
|
||||
let (read_fd, write_fd) = create_ready_pipe()?;
|
||||
let pid = unsafe { libc::fork() };
|
||||
if pid < 0 {
|
||||
let err = io::Error::last_os_error();
|
||||
close_fd(read_fd)?;
|
||||
close_fd(write_fd)?;
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
if pid == 0 {
|
||||
if close_fd(read_fd).is_err() {
|
||||
unsafe { libc::_exit(1) };
|
||||
}
|
||||
let result = run_local_bridge(uds_path, write_fd);
|
||||
if result.is_err() {
|
||||
unsafe { libc::_exit(1) };
|
||||
}
|
||||
unsafe { libc::_exit(0) };
|
||||
}
|
||||
|
||||
close_fd(write_fd)?;
|
||||
let mut port_bytes = [0_u8; 2];
|
||||
let mut read_file = unsafe { File::from_raw_fd(read_fd) };
|
||||
read_file.read_exact(&mut port_bytes)?;
|
||||
Ok(u16::from_be_bytes(port_bytes))
|
||||
}
|
||||
|
||||
fn run_local_bridge(uds_path: &Path, ready_fd: libc::c_int) -> io::Result<()> {
|
||||
set_parent_death_signal()?;
|
||||
let listener = bind_local_loopback_listener()?;
|
||||
let port = listener.local_addr()?.port();
|
||||
|
||||
let mut ready_file = unsafe { File::from_raw_fd(ready_fd) };
|
||||
ready_file.write_all(&port.to_be_bytes())?;
|
||||
drop(ready_file);
|
||||
|
||||
let uds_path = uds_path.to_path_buf();
|
||||
loop {
|
||||
let (tcp_stream, _) = listener.accept()?;
|
||||
let socket_path = uds_path.clone();
|
||||
std::thread::spawn(move || {
|
||||
let unix_stream = match UnixStream::connect(socket_path) {
|
||||
Ok(stream) => stream,
|
||||
Err(_) => return,
|
||||
};
|
||||
let _ = proxy_bidirectional(tcp_stream, unix_stream);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn bind_local_loopback_listener() -> io::Result<TcpListener> {
|
||||
match TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) {
|
||||
Ok(listener) => Ok(listener),
|
||||
Err(bind_err) => {
|
||||
let should_retry_after_lo_up = matches!(
|
||||
bind_err.raw_os_error(),
|
||||
Some(errno) if errno == libc::EADDRNOTAVAIL || errno == libc::ENETUNREACH
|
||||
);
|
||||
if !should_retry_after_lo_up {
|
||||
return Err(bind_err);
|
||||
}
|
||||
|
||||
ensure_loopback_interface_up()?;
|
||||
TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_loopback_interface_up() -> io::Result<()> {
|
||||
let fd = unsafe { libc::socket(libc::AF_INET, libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, 0) };
|
||||
if fd < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
|
||||
let mut ifreq = unsafe { std::mem::zeroed::<libc::ifreq>() };
|
||||
for (index, byte) in LOOPBACK_INTERFACE_NAME.iter().copied().enumerate() {
|
||||
ifreq.ifr_name[index] = byte as libc::c_char;
|
||||
}
|
||||
|
||||
let read_flags_result =
|
||||
unsafe { libc::ioctl(fd, libc::SIOCGIFFLAGS as libc::Ioctl, &mut ifreq) };
|
||||
if read_flags_result < 0 {
|
||||
let err = io::Error::last_os_error();
|
||||
let _ = close_fd(fd);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let current_flags = unsafe { ifreq.ifr_ifru.ifru_flags };
|
||||
let up_flag = libc::IFF_UP as libc::c_short;
|
||||
if (current_flags & up_flag) != up_flag {
|
||||
ifreq.ifr_ifru.ifru_flags = current_flags | up_flag;
|
||||
let set_flags_result =
|
||||
unsafe { libc::ioctl(fd, libc::SIOCSIFFLAGS as libc::Ioctl, &ifreq) };
|
||||
if set_flags_result < 0 {
|
||||
let err = io::Error::last_os_error();
|
||||
let _ = close_fd(fd);
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
|
||||
let mut addr_req = unsafe { std::mem::zeroed::<libc::ifreq>() };
|
||||
for (index, byte) in LOOPBACK_INTERFACE_NAME.iter().copied().enumerate() {
|
||||
addr_req.ifr_name[index] = byte as libc::c_char;
|
||||
}
|
||||
let loopback_addr = libc::sockaddr_in {
|
||||
sin_family: libc::AF_INET as libc::sa_family_t,
|
||||
sin_port: 0,
|
||||
sin_addr: libc::in_addr {
|
||||
s_addr: libc::htonl(libc::INADDR_LOOPBACK),
|
||||
},
|
||||
sin_zero: [0; 8],
|
||||
};
|
||||
unsafe {
|
||||
addr_req.ifr_ifru.ifru_addr =
|
||||
*(&loopback_addr as *const libc::sockaddr_in as *const libc::sockaddr);
|
||||
}
|
||||
let set_addr_result = unsafe { libc::ioctl(fd, libc::SIOCSIFADDR as libc::Ioctl, &addr_req) };
|
||||
if set_addr_result < 0 {
|
||||
let err = io::Error::last_os_error();
|
||||
let allow_existing_or_immutable_addr =
|
||||
matches!(err.raw_os_error(), Some(libc::EEXIST | libc::EPERM));
|
||||
if !allow_existing_or_immutable_addr {
|
||||
let _ = close_fd(fd);
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
|
||||
close_fd(fd)
|
||||
}
|
||||
|
||||
fn set_parent_death_signal() -> io::Result<()> {
|
||||
let res = unsafe { libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) };
|
||||
if res != 0 {
|
||||
Err(io::Error::last_os_error())
|
||||
} else if unsafe { libc::getppid() } == 1 {
|
||||
Err(io::Error::other("parent process already exited"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn proxy_bidirectional(mut tcp_stream: TcpStream, mut unix_stream: UnixStream) -> io::Result<()> {
|
||||
let mut tcp_reader = tcp_stream.try_clone()?;
|
||||
let mut unix_writer = unix_stream.try_clone()?;
|
||||
let tcp_to_unix = std::thread::spawn(move || std::io::copy(&mut tcp_reader, &mut unix_writer));
|
||||
let unix_to_tcp = std::io::copy(&mut unix_stream, &mut tcp_stream);
|
||||
let tcp_to_unix = tcp_to_unix
|
||||
.join()
|
||||
.map_err(|_| io::Error::other("bridge thread panicked"))?;
|
||||
tcp_to_unix?;
|
||||
unix_to_tcp?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_ready_pipe() -> io::Result<(libc::c_int, libc::c_int)> {
|
||||
let mut pipe_fds = [0; 2];
|
||||
let res = unsafe { libc::pipe2(pipe_fds.as_mut_ptr(), libc::O_CLOEXEC) };
|
||||
if res != 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok((pipe_fds[0], pipe_fds[1]))
|
||||
}
|
||||
|
||||
fn close_fd(fd: libc::c_int) -> io::Result<()> {
|
||||
let res = unsafe { libc::close(fd) };
|
||||
if res < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::PROXY_SOCKET_DIR_PREFIX;
|
||||
use super::ProxyRouteEntry;
|
||||
use super::ProxyRouteSpec;
|
||||
use super::cleanup_proxy_socket_dir;
|
||||
use super::cleanup_stale_proxy_socket_dirs_in;
|
||||
use super::default_proxy_port;
|
||||
use super::is_proxy_env_key;
|
||||
use super::parse_loopback_proxy_endpoint;
|
||||
use super::parse_proxy_socket_dir_owner_pid;
|
||||
use super::plan_proxy_routes;
|
||||
use super::rewrite_proxy_env_value;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn recognizes_proxy_env_keys_case_insensitively() {
|
||||
assert_eq!(is_proxy_env_key("HTTP_PROXY"), true);
|
||||
assert_eq!(is_proxy_env_key("http_proxy"), true);
|
||||
assert_eq!(is_proxy_env_key("PATH"), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_loopback_proxy_endpoint() {
|
||||
let endpoint = parse_loopback_proxy_endpoint("http://127.0.0.1:43128");
|
||||
assert_eq!(
|
||||
endpoint,
|
||||
Some(
|
||||
"127.0.0.1:43128"
|
||||
.parse::<SocketAddr>()
|
||||
.expect("valid socket")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_loopback_proxy_endpoint() {
|
||||
assert_eq!(
|
||||
parse_loopback_proxy_endpoint("http://example.com:3128"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_proxy_routes_only_includes_valid_loopback_endpoints() {
|
||||
let mut env = HashMap::new();
|
||||
env.insert(
|
||||
"HTTP_PROXY".to_string(),
|
||||
"http://127.0.0.1:43128".to_string(),
|
||||
);
|
||||
env.insert(
|
||||
"HTTPS_PROXY".to_string(),
|
||||
"http://example.com:3128".to_string(),
|
||||
);
|
||||
env.insert("PATH".to_string(), "/usr/bin".to_string());
|
||||
|
||||
let plan = plan_proxy_routes(&env);
|
||||
assert_eq!(plan.has_proxy_config, true);
|
||||
assert_eq!(plan.routes.len(), 1);
|
||||
assert_eq!(plan.routes[0].env_key, "HTTP_PROXY");
|
||||
assert_eq!(
|
||||
plan.routes[0].endpoint,
|
||||
"127.0.0.1:43128"
|
||||
.parse::<SocketAddr>()
|
||||
.expect("valid socket")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrites_proxy_url_to_local_loopback_port() {
|
||||
let rewritten =
|
||||
rewrite_proxy_env_value("socks5h://127.0.0.1:8081", 43210).expect("rewritten value");
|
||||
assert_eq!(rewritten, "socks5h://127.0.0.1:43210");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_proxy_ports_match_expected_schemes() {
|
||||
assert_eq!(default_proxy_port("http"), 80);
|
||||
assert_eq!(default_proxy_port("https"), 443);
|
||||
assert_eq!(default_proxy_port("socks5h"), 1080);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_proxy_socket_dir_removes_bridge_artifacts() {
|
||||
let root = tempfile::tempdir().expect("tempdir should create");
|
||||
let socket_dir = root.path().join("codex-linux-sandbox-proxy-test");
|
||||
std::fs::create_dir(&socket_dir).expect("socket dir should create");
|
||||
let marker = socket_dir.join("bridge.sock");
|
||||
std::fs::write(&marker, b"test").expect("marker should write");
|
||||
|
||||
cleanup_proxy_socket_dir(socket_dir.as_path()).expect("cleanup should succeed");
|
||||
|
||||
assert_eq!(socket_dir.exists(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_route_spec_serialization_omits_proxy_urls() {
|
||||
let spec = ProxyRouteSpec {
|
||||
routes: vec![ProxyRouteEntry {
|
||||
env_key: "HTTP_PROXY".to_string(),
|
||||
uds_path: PathBuf::from("/tmp/proxy-route-0.sock"),
|
||||
}],
|
||||
};
|
||||
let serialized = serde_json::to_string(&spec).expect("proxy route spec should serialize");
|
||||
|
||||
assert_eq!(
|
||||
serialized,
|
||||
r#"{"routes":[{"env_key":"HTTP_PROXY","uds_path":"/tmp/proxy-route-0.sock"}]}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_proxy_socket_dir_owner_pid_reads_owner_pid() {
|
||||
assert_eq!(
|
||||
parse_proxy_socket_dir_owner_pid("codex-linux-sandbox-proxy-1234-0"),
|
||||
Some(1234)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_proxy_socket_dir_owner_pid("codex-linux-sandbox-proxy-x"),
|
||||
None
|
||||
);
|
||||
assert_eq!(parse_proxy_socket_dir_owner_pid("not-a-proxy-dir"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_stale_proxy_socket_dirs_removes_dead_pid_directories() {
|
||||
let root = tempfile::tempdir().expect("tempdir should create");
|
||||
let dead_dir = root
|
||||
.path()
|
||||
.join(format!("{PROXY_SOCKET_DIR_PREFIX}{}-0", u32::MAX));
|
||||
std::fs::create_dir(&dead_dir).expect("dead dir should create");
|
||||
|
||||
let alive_dir = root
|
||||
.path()
|
||||
.join(format!("{PROXY_SOCKET_DIR_PREFIX}{}-1", std::process::id()));
|
||||
std::fs::create_dir(&alive_dir).expect("alive dir should create");
|
||||
|
||||
let unrelated_dir = root.path().join("unrelated-proxy-dir");
|
||||
std::fs::create_dir(&unrelated_dir).expect("unrelated dir should create");
|
||||
|
||||
cleanup_stale_proxy_socket_dirs_in(root.path()).expect("stale cleanup should succeed");
|
||||
|
||||
assert_eq!(dead_dir.exists(), false);
|
||||
assert_eq!(alive_dir.exists(), true);
|
||||
assert_eq!(unrelated_dir.exists(), true);
|
||||
}
|
||||
}
|
||||
313
codex-rs/linux-sandbox/tests/suite/managed_proxy.rs
Normal file
313
codex-rs/linux-sandbox/tests/suite/managed_proxy.rs
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
#![cfg(target_os = "linux")]
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use codex_core::config::types::ShellEnvironmentPolicy;
|
||||
use codex_core::exec_env::create_env;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashMap;
|
||||
use std::io::Read;
|
||||
use std::io::Write;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::net::TcpListener;
|
||||
use std::process::Output;
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
use tokio::process::Command;
|
||||
|
||||
const BWRAP_UNAVAILABLE_ERR: &str = "build-time bubblewrap is not available in this build.";
|
||||
const NETWORK_TIMEOUT_MS: u64 = 4_000;
|
||||
const MANAGED_PROXY_PERMISSION_ERR_SNIPPETS: &[&str] = &[
|
||||
"loopback: Failed RTM_NEWADDR",
|
||||
"loopback: Failed RTM_NEWLINK",
|
||||
"setting up uid map: Permission denied",
|
||||
"No permissions to create a new namespace",
|
||||
"error isolating Linux network namespace for proxy mode",
|
||||
];
|
||||
|
||||
const PROXY_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",
|
||||
];
|
||||
|
||||
fn create_env_from_core_vars() -> HashMap<String, String> {
|
||||
let policy = ShellEnvironmentPolicy::default();
|
||||
create_env(&policy, None)
|
||||
}
|
||||
|
||||
fn strip_proxy_env(env: &mut HashMap<String, String>) {
|
||||
for key in PROXY_ENV_KEYS {
|
||||
env.remove(*key);
|
||||
let lower = key.to_ascii_lowercase();
|
||||
env.remove(lower.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
fn is_bwrap_unavailable_output(output: &Output) -> bool {
|
||||
String::from_utf8_lossy(&output.stderr).contains(BWRAP_UNAVAILABLE_ERR)
|
||||
}
|
||||
|
||||
async fn should_skip_bwrap_tests() -> bool {
|
||||
let mut env = create_env_from_core_vars();
|
||||
strip_proxy_env(&mut env);
|
||||
|
||||
let output = run_linux_sandbox_direct(
|
||||
&["bash", "-c", "true"],
|
||||
&SandboxPolicy::new_read_only_policy(),
|
||||
false,
|
||||
env,
|
||||
NETWORK_TIMEOUT_MS,
|
||||
)
|
||||
.await;
|
||||
is_bwrap_unavailable_output(&output)
|
||||
}
|
||||
|
||||
fn is_managed_proxy_permission_error(stderr: &str) -> bool {
|
||||
MANAGED_PROXY_PERMISSION_ERR_SNIPPETS
|
||||
.iter()
|
||||
.any(|snippet| stderr.contains(snippet))
|
||||
}
|
||||
|
||||
async fn managed_proxy_skip_reason() -> Option<String> {
|
||||
if should_skip_bwrap_tests().await {
|
||||
return Some("vendored bwrap was not built in this environment".to_string());
|
||||
}
|
||||
|
||||
let mut env = create_env_from_core_vars();
|
||||
strip_proxy_env(&mut env);
|
||||
env.insert("HTTP_PROXY".to_string(), "http://127.0.0.1:9".to_string());
|
||||
|
||||
let output = run_linux_sandbox_direct(
|
||||
&["bash", "-c", "true"],
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
true,
|
||||
env,
|
||||
NETWORK_TIMEOUT_MS,
|
||||
)
|
||||
.await;
|
||||
if output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if is_managed_proxy_permission_error(stderr.as_ref()) {
|
||||
return Some(format!(
|
||||
"managed proxy requires kernel namespace privileges unavailable here: {}",
|
||||
stderr.trim()
|
||||
));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
async fn run_linux_sandbox_direct(
|
||||
command: &[&str],
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
allow_network_for_proxy: bool,
|
||||
env: HashMap<String, String>,
|
||||
timeout_ms: u64,
|
||||
) -> Output {
|
||||
let cwd = match std::env::current_dir() {
|
||||
Ok(cwd) => cwd,
|
||||
Err(err) => panic!("cwd should exist: {err}"),
|
||||
};
|
||||
let policy_json = match serde_json::to_string(sandbox_policy) {
|
||||
Ok(policy_json) => policy_json,
|
||||
Err(err) => panic!("policy should serialize: {err}"),
|
||||
};
|
||||
|
||||
let mut args = vec![
|
||||
"--sandbox-policy-cwd".to_string(),
|
||||
cwd.to_string_lossy().to_string(),
|
||||
"--sandbox-policy".to_string(),
|
||||
policy_json,
|
||||
"--use-bwrap-sandbox".to_string(),
|
||||
];
|
||||
if allow_network_for_proxy {
|
||||
args.push("--allow-network-for-proxy".to_string());
|
||||
}
|
||||
args.push("--".to_string());
|
||||
args.extend(command.iter().map(|entry| (*entry).to_string()));
|
||||
|
||||
let mut cmd = Command::new(env!("CARGO_BIN_EXE_codex-linux-sandbox"));
|
||||
cmd.args(args)
|
||||
.current_dir(cwd)
|
||||
.env_clear()
|
||||
.envs(env)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
let output = match tokio::time::timeout(Duration::from_millis(timeout_ms), cmd.output()).await {
|
||||
Ok(output) => output,
|
||||
Err(err) => panic!("sandbox command should not time out: {err}"),
|
||||
};
|
||||
match output {
|
||||
Ok(output) => output,
|
||||
Err(err) => panic!("sandbox command should execute: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn managed_proxy_mode_fails_closed_without_proxy_env() {
|
||||
if let Some(skip_reason) = managed_proxy_skip_reason().await {
|
||||
eprintln!("skipping managed proxy test: {skip_reason}");
|
||||
return;
|
||||
}
|
||||
|
||||
let mut env = create_env_from_core_vars();
|
||||
strip_proxy_env(&mut env);
|
||||
|
||||
let output = run_linux_sandbox_direct(
|
||||
&["bash", "-c", "true"],
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
true,
|
||||
env,
|
||||
NETWORK_TIMEOUT_MS,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(output.status.success(), false);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("managed proxy mode requires proxy environment variables"),
|
||||
"expected fail-closed managed-proxy message, got stderr: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn managed_proxy_mode_routes_through_bridge_and_blocks_direct_egress() {
|
||||
if let Some(skip_reason) = managed_proxy_skip_reason().await {
|
||||
eprintln!("skipping managed proxy test: {skip_reason}");
|
||||
return;
|
||||
}
|
||||
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind proxy listener");
|
||||
let proxy_port = listener
|
||||
.local_addr()
|
||||
.expect("proxy listener local addr")
|
||||
.port();
|
||||
let (request_tx, request_rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().expect("accept proxy connection");
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(3)))
|
||||
.expect("set read timeout");
|
||||
let mut buf = [0_u8; 4096];
|
||||
let read = stream.read(&mut buf).expect("read proxy request");
|
||||
let request = String::from_utf8_lossy(&buf[..read]).to_string();
|
||||
request_tx.send(request).expect("send proxy request");
|
||||
stream
|
||||
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK")
|
||||
.expect("write proxy response");
|
||||
});
|
||||
|
||||
let mut env = create_env_from_core_vars();
|
||||
strip_proxy_env(&mut env);
|
||||
env.insert(
|
||||
"HTTP_PROXY".to_string(),
|
||||
format!("http://127.0.0.1:{proxy_port}"),
|
||||
);
|
||||
|
||||
let routed_output = run_linux_sandbox_direct(
|
||||
&[
|
||||
"bash",
|
||||
"-c",
|
||||
"proxy=\"${HTTP_PROXY#*://}\"; host=\"${proxy%%:*}\"; port=\"${proxy##*:}\"; exec 3<>/dev/tcp/${host}/${port}; printf 'GET http://example.com/ HTTP/1.1\\r\\nHost: example.com\\r\\n\\r\\n' >&3; IFS= read -r line <&3; printf '%s\\n' \"$line\"",
|
||||
],
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
true,
|
||||
env.clone(),
|
||||
NETWORK_TIMEOUT_MS,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
routed_output.status.success(),
|
||||
true,
|
||||
"expected routed command to execute successfully; status={:?}; stdout={}; stderr={}",
|
||||
routed_output.status.code(),
|
||||
String::from_utf8_lossy(&routed_output.stdout),
|
||||
String::from_utf8_lossy(&routed_output.stderr)
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&routed_output.stdout);
|
||||
assert!(
|
||||
stdout.contains("HTTP/1.1 200 OK"),
|
||||
"expected bridge-routed proxy response, got stdout: {stdout}"
|
||||
);
|
||||
|
||||
let request = request_rx
|
||||
.recv_timeout(Duration::from_secs(3))
|
||||
.expect("expected proxy request");
|
||||
assert!(
|
||||
request.contains("GET http://example.com/ HTTP/1.1"),
|
||||
"expected HTTP proxy absolute-form request, got request: {request}"
|
||||
);
|
||||
|
||||
let direct_egress_output = run_linux_sandbox_direct(
|
||||
&["bash", "-c", "echo hi > /dev/tcp/192.0.2.1/80"],
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
true,
|
||||
env,
|
||||
NETWORK_TIMEOUT_MS,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(direct_egress_output.status.success(), false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn managed_proxy_mode_denies_af_unix_creation_for_user_command() {
|
||||
if let Some(skip_reason) = managed_proxy_skip_reason().await {
|
||||
eprintln!("skipping managed proxy test: {skip_reason}");
|
||||
return;
|
||||
}
|
||||
|
||||
let python_available = Command::new("bash")
|
||||
.arg("-c")
|
||||
.arg("command -v python3 >/dev/null")
|
||||
.status()
|
||||
.await
|
||||
.expect("python3 probe should execute")
|
||||
.success();
|
||||
if !python_available {
|
||||
eprintln!("skipping managed proxy AF_UNIX test: python3 is unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
let mut env = create_env_from_core_vars();
|
||||
strip_proxy_env(&mut env);
|
||||
env.insert("HTTP_PROXY".to_string(), "http://127.0.0.1:9".to_string());
|
||||
|
||||
let output = run_linux_sandbox_direct(
|
||||
&[
|
||||
"python3",
|
||||
"-c",
|
||||
"import socket,sys\ntry:\n socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)\nexcept PermissionError:\n sys.exit(0)\nexcept OSError:\n sys.exit(2)\nsys.exit(1)\n",
|
||||
],
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
true,
|
||||
env,
|
||||
NETWORK_TIMEOUT_MS,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
output.status.code(),
|
||||
Some(0),
|
||||
"expected AF_UNIX creation to be denied cleanly for user command; status={:?}; stdout={}; stderr={}",
|
||||
output.status.code(),
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
// Aggregates all former standalone integration tests as modules.
|
||||
mod landlock;
|
||||
mod managed_proxy;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue