Honor explicit Unix socket grants in the Linux managed sandbox (#45534)

## Why

Linux proxy-routed sandboxing denied standalone Unix sockets even when the effective network policy enabled `dangerously_allow_all_unix_sockets`.

## What changed

- Carry Unix socket permissions in `ManagedNetworkSandboxContext` and pass the prepared context through Linux sandbox launches with `--managed-network`.
- Allow `AF_UNIX` socket creation in proxy-routed mode when `dangerously_allow_all_unix_sockets` is enabled, while preserving network namespace isolation and restrictions on other socket families.
- Keep standalone Unix sockets denied by default and for path-only grants. Default missing fields in older serialized contexts to restrictive values.

## Testing

Add coverage for policy preparation and transport, legacy deserialization, and malformed policy rejection. Add a Linux integration test covering default denial, path-only denial, and explicit allow-all access, while checking that direct TCP access and `AF_NETLINK`/`AF_VSOCK` sockets remain blocked.

GitOrigin-RevId: 2695b945ad3e59fcb3faf7662d852a26650af16c
This commit is contained in:
viyatb-oai
2026-09-14 22:23:29 +00:00
committed by copyberry
parent d39cfa8a2d
commit 99914f4950
18 changed files with 599 additions and 68 deletions

View File

@@ -23,7 +23,6 @@ use codex_protocol::config_types::SandboxMode;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::SandboxEnforcement;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_sandboxing::landlock::allow_network_for_proxy;
use codex_sandboxing::landlock::create_linux_sandbox_command_args_for_permission_profile;
#[cfg(target_os = "macos")]
use codex_sandboxing::seatbelt::CreateSeatbeltCommandArgsParams;
@@ -364,6 +363,7 @@ async fn run_command_under_sandbox(
.map(codex_core::config::StartedNetworkProxy::proxy);
// Proxy containment depends on whether a proxy is active, not whether its
// policy came from managed requirements.
#[cfg(target_os = "macos")]
let enforce_managed_network = network.is_some();
let managed_mitm_ca_trust_bundle_path = match network.as_ref() {
Some(network) => network.managed_mitm_ca_trust_bundle_path(),
@@ -423,13 +423,20 @@ async fn run_command_under_sandbox(
.codex_linux_sandbox_exe
.expect("codex-linux-sandbox executable not found");
let network_sandbox_policy = runtime_permission_profile.network_sandbox_policy();
let (env, managed_network) = if let Some(network) = network.as_ref() {
let prepared =
network.prepare_for_optional_environment(env, /*environment_id*/ None)?;
(prepared.env, Some(prepared.sandbox_context))
} else {
(env, None)
};
let args = create_linux_sandbox_command_args_for_permission_profile(
command,
cwd.as_path(),
&runtime_permission_profile,
sandbox_policy_cwd.as_path(),
use_legacy_landlock,
allow_network_for_proxy(enforce_managed_network),
managed_network.as_ref(),
);
spawn_debug_sandbox_child(
codex_linux_sandbox_exe,
@@ -438,11 +445,7 @@ async fn run_command_under_sandbox(
cwd.to_path_buf(),
network_sandbox_policy,
env,
|env_map| {
if let Some(network) = network.as_ref() {
network.apply_to_env(env_map);
}
},
|_| {},
)
.await?
}

View File

@@ -336,6 +336,7 @@ fn exec_server_env_keeps_command_native_and_carries_sandbox_context() {
let managed_network = ManagedNetworkSandboxContext {
loopback_ports: vec![43123],
allow_local_binding: false,
..Default::default()
};
let command = || SandboxCommand {
program: "/bin/bash".into(),

View File

@@ -168,6 +168,7 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() {
let managed_network = ManagedNetworkSandboxContext {
loopback_ports: vec![43123],
allow_local_binding: false,
..Default::default()
};
let mut request = ExecRequest {
command: vec!["bash".to_string(), "-lc".to_string(), "true".to_string()],

View File

@@ -962,6 +962,8 @@ mod tests {
managed_network: Some(ManagedNetworkSandboxContext {
loopback_ports: vec![43123, 48081],
allow_local_binding: false,
allow_unix_sockets: vec!["/tmp/allowed.sock".to_string()],
dangerously_allow_all_unix_sockets: true,
}),
network_proxy: Some(
RemoteNetworkProxyLaunchConfig::new(
@@ -990,6 +992,8 @@ mod tests {
serde_json::json!({
"loopbackPorts": [43123, 48081],
"allowLocalBinding": false,
"allowUnixSockets": ["/tmp/allowed.sock"],
"dangerouslyAllowAllUnixSockets": true,
})
);
assert_eq!(
@@ -1023,6 +1027,52 @@ mod tests {
assert!(legacy_serialized.get("metadata").is_none());
}
#[test]
fn exec_params_defaults_legacy_managed_network_unix_socket_policy() {
let cwd =
PathUri::from_host_native_path(std::env::current_dir().expect("current directory"))
.expect("cwd URI");
let legacy: ExecParams = serde_json::from_value(serde_json::json!({
"processId": "legacy-managed-network",
"argv": ["true"],
"cwd": cwd,
"env": {},
"tty": false,
"arg0": null,
"enforceManagedNetwork": true,
"managedNetwork": {
"loopbackPorts": [43123],
"allowLocalBinding": true,
},
}))
.expect("deserialize legacy managed network context");
assert_eq!(
legacy,
ExecParams {
process_id: ProcessId::from("legacy-managed-network"),
metadata: None,
argv: vec!["true".to_string()],
cwd,
env_policy: None,
shell_snapshot: None,
env: HashMap::new(),
tty: false,
pipe_stdin: false,
arg0: None,
sandbox: None,
enforce_managed_network: true,
managed_network: Some(ManagedNetworkSandboxContext {
loopback_ports: vec![43123],
allow_local_binding: true,
allow_unix_sockets: Vec::new(),
dangerously_allow_all_unix_sockets: false,
}),
network_proxy: None,
}
);
}
#[test]
fn environment_info_accepts_legacy_response_without_cwd() {
let info: EnvironmentInfo = serde_json::from_value(serde_json::json!({

View File

@@ -240,6 +240,7 @@ async fn sandbox_request_allows_prepared_managed_proxy_port() {
managed_network: Some(ManagedNetworkSandboxContext {
loopback_ports: vec![43123],
allow_local_binding: false,
..Default::default()
}),
network_proxy: None,
};

View File

@@ -5,6 +5,7 @@
use std::collections::BTreeMap;
use std::path::Path;
use codex_network_proxy::ManagedNetworkSandboxContext;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result;
use codex_protocol::error::SandboxErr;
@@ -43,15 +44,15 @@ pub(crate) fn apply_permission_profile_to_current_thread(
permission_profile: &PermissionProfile,
cwd: &Path,
apply_landlock_fs: bool,
allow_network_for_proxy: bool,
proxy_routed_network: bool,
managed_network: Option<&ManagedNetworkSandboxContext>,
proxy_routing_active: bool,
) -> Result<()> {
let (file_system_sandbox_policy, network_sandbox_policy) =
permission_profile.to_runtime_permissions();
let network_seccomp_mode = network_seccomp_mode(
network_sandbox_policy,
allow_network_for_proxy,
proxy_routed_network,
managed_network.is_some(),
proxy_routing_active,
)
.or_else(|| {
// VM sockets can reach host services outside the filesystem sandbox.
@@ -73,7 +74,7 @@ pub(crate) fn apply_permission_profile_to_current_thread(
}
if let Some(mode) = network_seccomp_mode {
install_network_seccomp_filter_on_current_thread(mode)?;
install_network_seccomp_filter_on_current_thread(mode, managed_network)?;
}
if apply_landlock_fs && !file_system_sandbox_policy.has_full_disk_write_access() {
@@ -177,6 +178,7 @@ fn install_filesystem_landlock_rules_on_current_thread(
/// inherits it.
fn install_network_seccomp_filter_on_current_thread(
mode: NetworkSeccompMode,
managed_network: Option<&ManagedNetworkSandboxContext>,
) -> std::result::Result<(), SandboxErr> {
fn deny_syscall(rules: &mut BTreeMap<i64, Vec<SeccompRule>>, nr: i64) {
rules.insert(nr, vec![]); // empty rule vec = unconditional match
@@ -230,12 +232,10 @@ fn install_network_seccomp_filter_on_current_thread(
}
NetworkSeccompMode::ProxyRouted => {
// In proxy-routed mode we allow IP sockets in the isolated
// namespace (used to reach the local TCP bridge) but deny socket()
// for all other families, including AF_UNIX. Only AF_UNIX
// socketpair() remains available for process-local IPC because it
// cannot connect to a socket outside the sandbox or bypass the
// bridge.
let deny_non_ip_socket = SeccompRule::new(vec![
// namespace (used to reach the local TCP bridge). Standalone Unix
// sockets require an explicit managed-policy grant; all other
// socket families remain denied.
let mut denied_socket_conditions = vec![
SeccompCondition::new(
0,
SeccompCmpArgLen::Dword,
@@ -248,7 +248,16 @@ fn install_network_seccomp_filter_on_current_thread(
SeccompCmpOp::Ne,
libc::AF_INET6 as u64,
)?,
])?;
];
if managed_network.is_some_and(|context| context.dangerously_allow_all_unix_sockets) {
denied_socket_conditions.push(SeccompCondition::new(
0,
SeccompCmpArgLen::Dword,
SeccompCmpOp::Ne,
libc::AF_UNIX as u64,
)?);
}
let deny_non_ip_socket = SeccompRule::new(denied_socket_conditions)?;
let deny_non_unix_socketpair = SeccompRule::new(vec![SeccompCondition::new(
0,
SeccompCmpArgLen::Dword,

View File

@@ -29,6 +29,7 @@ use crate::launcher::exec_bwrap;
use crate::launcher::preferred_bwrap_supports_argv0;
use crate::proxy_routing::activate_proxy_routes_in_netns;
use crate::proxy_routing::prepare_host_proxy_route_spec;
use codex_network_proxy::ManagedNetworkSandboxContext;
use codex_protocol::error::Result as CodexResult;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::FileSystemAccessMode;
@@ -123,13 +124,13 @@ 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 with
/// managed routing bridges.
#[arg(long = "allow-network-for-proxy", hide = true, default_value_t = false)]
pub allow_network_for_proxy: bool,
/// Effective managed-network policy prepared for this command launch.
#[arg(
long,
hide = true,
value_parser = parse_managed_network
)]
pub managed_network: Option<ManagedNetworkSandboxContext>,
/// Internal route spec used for managed proxy routing in bwrap mode.
#[arg(long = "proxy-route-spec", hide = true)]
@@ -164,12 +165,13 @@ pub fn run_main() -> ! {
permission_profile,
use_legacy_landlock,
apply_seccomp_then_exec,
allow_network_for_proxy,
managed_network,
proxy_route_spec,
verify_fd_mounts,
no_proc,
command,
} = LandlockCommand::parse();
let allow_network_for_proxy = managed_network.is_some();
if command.is_empty() {
panic!("No command specified to execute.");
@@ -236,7 +238,7 @@ pub fn run_main() -> ! {
&permission_profile,
&sandbox_policy_cwd,
/*apply_landlock_fs*/ false,
allow_network_for_proxy,
managed_network.as_ref(),
proxy_routing_active,
) {
panic!("error applying Linux sandbox restrictions: {e:?}");
@@ -282,8 +284,8 @@ pub fn run_main() -> ! {
&permission_profile,
&sandbox_policy_cwd,
/*apply_landlock_fs*/ false,
allow_network_for_proxy,
/*proxy_routed_network*/ false,
managed_network.as_ref(),
/*proxy_routing_active*/ false,
) {
panic!("error applying Linux sandbox restrictions: {e:?}");
}
@@ -305,7 +307,7 @@ pub fn run_main() -> ! {
sandbox_policy_cwd: &sandbox_policy_cwd,
command_cwd: command_cwd.as_deref(),
permission_profile: &permission_profile,
allow_network_for_proxy,
managed_network,
proxy_route_spec,
command,
});
@@ -325,8 +327,8 @@ pub fn run_main() -> ! {
&permission_profile,
&sandbox_policy_cwd,
/*apply_landlock_fs*/ true,
allow_network_for_proxy,
/*proxy_routed_network*/ false,
managed_network.as_ref(),
/*proxy_routing_active*/ false,
) {
panic!("error applying legacy Linux sandbox restrictions: {e:?}");
}
@@ -357,6 +359,11 @@ fn parse_permission_profile(value: &str) -> std::result::Result<PermissionProfil
serde_json::from_str(value).map_err(|err| format!("invalid permission profile JSON: {err}"))
}
fn parse_managed_network(value: &str) -> std::result::Result<ManagedNetworkSandboxContext, String> {
serde_json::from_str(value)
.map_err(|err| format!("invalid managed network context JSON: {err}"))
}
fn resolve_permission_profile(
permission_profile: Option<PermissionProfile>,
) -> Result<EffectivePermissions, ResolvePermissionProfileError> {
@@ -1521,7 +1528,7 @@ struct InnerSeccompCommandArgs<'a> {
sandbox_policy_cwd: &'a Path,
command_cwd: Option<&'a Path>,
permission_profile: &'a PermissionProfile,
allow_network_for_proxy: bool,
managed_network: Option<ManagedNetworkSandboxContext>,
proxy_route_spec: Option<String>,
command: Vec<String>,
}
@@ -1532,7 +1539,7 @@ fn build_inner_seccomp_command(args: InnerSeccompCommandArgs<'_>) -> Vec<String>
sandbox_policy_cwd,
command_cwd,
permission_profile,
allow_network_for_proxy,
managed_network,
proxy_route_spec,
command,
} = args;
@@ -1559,8 +1566,12 @@ fn build_inner_seccomp_command(args: InnerSeccompCommandArgs<'_>) -> Vec<String>
permission_profile_json,
"--apply-seccomp-then-exec".to_string(),
]);
if allow_network_for_proxy {
inner.push("--allow-network-for-proxy".to_string());
if let Some(managed_network) = managed_network {
inner.push("--managed-network".to_string());
inner.push(
serde_json::to_string(&managed_network)
.unwrap_or_else(|err| panic!("failed to serialize managed network context: {err}")),
);
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());

View File

@@ -5,6 +5,8 @@ use crate::linux_run_main::install_bwrap_signal_forwarders;
#[cfg(test)]
use crate::linux_run_main::wait_for_bwrap_child;
#[cfg(test)]
use codex_network_proxy::ManagedNetworkSandboxContext;
#[cfg(test)]
use codex_protocol::models::PermissionProfile;
#[cfg(test)]
use codex_protocol::protocol::FileSystemSandboxPolicy;
@@ -553,17 +555,63 @@ fn run_bwrap_signal_forwarder_test_supervisor() -> ! {
#[test]
fn managed_proxy_inner_command_includes_route_spec() {
let permission_profile = read_only_permission_profile();
let args = build_inner_seccomp_command(InnerSeccompCommandArgs {
sandbox_policy_cwd: Path::new("/tmp"),
command_cwd: Some(Path::new("/tmp/link")),
permission_profile: &permission_profile,
allow_network_for_proxy: true,
proxy_route_spec: Some("{\"routes\":[]}".to_string()),
command: vec!["/bin/true".to_string()],
});
for managed_network in [
ManagedNetworkSandboxContext::default(),
ManagedNetworkSandboxContext {
loopback_ports: vec![8080, 9090],
allow_local_binding: true,
allow_unix_sockets: vec!["/tmp/daemon.sock".to_string()],
dangerously_allow_all_unix_sockets: true,
},
] {
let args = build_inner_seccomp_command(InnerSeccompCommandArgs {
sandbox_policy_cwd: Path::new("/tmp"),
command_cwd: Some(Path::new("/tmp/link")),
permission_profile: &permission_profile,
managed_network: Some(managed_network.clone()),
proxy_route_spec: Some("{\"routes\":[]}".to_string()),
command: vec!["/bin/true".to_string()],
});
assert!(args.iter().any(|arg| arg == "--proxy-route-spec"));
assert!(args.iter().any(|arg| arg == "{\"routes\":[]}"));
assert!(args.iter().any(|arg| arg == "--proxy-route-spec"));
assert!(args.iter().any(|arg| arg == "{\"routes\":[]}"));
let parsed = LandlockCommand::try_parse_from(args)
.expect("inner command should preserve the managed network policy");
assert_eq!(parsed.managed_network, Some(managed_network));
}
}
#[test]
fn managed_network_policy_alone_enables_proxy_mode() {
let parsed = LandlockCommand::try_parse_from([
"codex-linux-sandbox",
"--sandbox-policy-cwd",
"/tmp",
"--managed-network",
"{}",
"--",
"/bin/true",
])
.expect("managed network context should enable proxy mode on its own");
assert_eq!(
parsed.managed_network,
Some(ManagedNetworkSandboxContext::default())
);
}
#[test]
fn malformed_managed_network_policy_is_rejected() {
let error = LandlockCommand::try_parse_from([
"codex-linux-sandbox",
"--sandbox-policy-cwd",
"/tmp",
"--managed-network",
"{\"dangerouslyAllowAllUnixSockets\":\"true\"}",
"--",
"/bin/true",
])
.expect_err("managed network policy should reject a non-boolean grant");
assert_eq!(error.kind(), clap::error::ErrorKind::ValueValidation);
}
#[test]
@@ -573,7 +621,7 @@ fn inner_command_includes_permission_profile_flag() {
sandbox_policy_cwd: Path::new("/tmp"),
command_cwd: Some(Path::new("/tmp/link")),
permission_profile: &permission_profile,
allow_network_for_proxy: false,
managed_network: None,
proxy_route_spec: None,
command: vec!["/bin/true".to_string()],
});
@@ -592,12 +640,15 @@ fn non_managed_inner_command_omits_route_spec() {
sandbox_policy_cwd: Path::new("/tmp"),
command_cwd: Some(Path::new("/tmp/link")),
permission_profile: &permission_profile,
allow_network_for_proxy: false,
managed_network: None,
proxy_route_spec: None,
command: vec!["/bin/true".to_string()],
});
assert!(!args.iter().any(|arg| arg == "--proxy-route-spec"));
let parsed = LandlockCommand::try_parse_from(args)
.expect("unmanaged inner command should preserve ordinary sandbox mode");
assert_eq!(parsed.managed_network, None);
}
#[test]
@@ -608,7 +659,7 @@ fn managed_proxy_inner_command_requires_route_spec() {
sandbox_policy_cwd: Path::new("/tmp"),
command_cwd: Some(Path::new("/tmp/link")),
permission_profile: &permission_profile,
allow_network_for_proxy: true,
managed_network: Some(ManagedNetworkSandboxContext::default()),
proxy_route_spec: None,
command: vec!["/bin/true".to_string()],
})

View File

@@ -36,6 +36,9 @@ use tempfile::NamedTempFile;
use tokio::process::Command;
use url::Url;
#[path = "managed_proxy_unix_sockets_tests.rs"]
mod unix_sockets;
const BWRAP_UNAVAILABLE_ERR: &str = "bubblewrap is unavailable: no system bwrap was found";
const NETWORK_TIMEOUT_MS: u64 = 4_000;
const OPERATION_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 8);
@@ -171,7 +174,8 @@ fn linux_sandbox_command(
permission_profile_json,
];
if allow_network_for_proxy {
args.push("--allow-network-for-proxy".to_string());
args.push("--managed-network".to_string());
args.push("{}".to_string());
}
args.push("--".to_string());
args.extend(command.iter().map(|entry| (*entry).to_string()));

View File

@@ -0,0 +1,187 @@
//! Explicit Unix-socket grants preserve managed-proxy network isolation.
use super::*;
use codex_network_proxy::ConfigReloader;
use codex_network_proxy::ConfigReloaderFuture;
use codex_network_proxy::ConfigState;
use codex_network_proxy::NetworkProxy;
use codex_network_proxy::NetworkProxyConfig;
use codex_network_proxy::NetworkProxyConstraints;
use codex_network_proxy::NetworkProxyState;
use codex_network_proxy::build_config_state;
use pretty_assertions::assert_eq;
use std::os::unix::net::UnixListener;
use std::os::unix::net::UnixStream;
use std::sync::Arc;
struct TestConfigReloader(ConfigState);
impl ConfigReloader for TestConfigReloader {
fn source_label(&self) -> String {
"managed proxy Unix socket test".to_string()
}
fn maybe_reload(&self) -> ConfigReloaderFuture<'_, Option<ConfigState>> {
Box::pin(async { Ok(None) })
}
fn reload_now(&self) -> ConfigReloaderFuture<'_, ConfigState> {
Box::pin(async { Ok(self.0.clone()) })
}
}
#[tokio::test]
async fn managed_proxy_allow_all_unix_sockets_preserves_network_isolation() {
if let Some(reason) = managed_proxy_skip_reason().await {
eprintln!("skipping managed proxy Unix socket test: {reason}");
return;
}
if !Command::new("python3")
.arg("--version")
.output()
.await
.is_ok_and(|output| output.status.success())
{
eprintln!("skipping managed proxy Unix socket test: python3 is unavailable");
return;
}
let temp = tempfile::tempdir().expect("socket directory");
let socket_path = temp.path().join("daemon.sock");
let listener = UnixListener::bind(&socket_path).expect("host Unix listener");
let mut control = UnixStream::connect(&socket_path).expect("host Unix control connection");
let (mut control_peer, _) = listener.accept().expect("accept host Unix control");
control.write_all(b"ok").expect("write host control");
let mut control_payload = [0; 2];
control_peer
.read_exact(&mut control_payload)
.expect("read host control");
assert_eq!(control_payload, *b"ok");
// Use another loopback address so the namespace's proxy listener cannot
// collide with this host-only endpoint even if it selects the same port.
let tcp_listener =
TcpListener::bind((Ipv4Addr::new(127, 0, 0, 2), 0)).expect("host TCP listener");
let tcp_addr = tcp_listener.local_addr().expect("host TCP address");
let _tcp_control = TcpStream::connect(tcp_addr).expect("host TCP control connection");
let _tcp_peer = tcp_listener.accept().expect("accept host TCP control");
listener
.set_nonblocking(true)
.expect("nonblocking listener");
let server = std::thread::spawn(move || {
let deadline = Instant::now() + OPERATION_TIMEOUT * 2;
loop {
match listener.accept() {
Ok((mut stream, _)) => {
stream
.set_read_timeout(Some(OPERATION_TIMEOUT))
.expect("set Unix read timeout");
let mut request = [0; 4];
stream.read_exact(&mut request).expect("read Unix request");
assert_eq!(request, *b"ping");
stream.write_all(b"pong").expect("write Unix response");
break;
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
assert!(Instant::now() < deadline, "Unix request timed out");
std::thread::sleep(Duration::from_millis(10));
}
Err(error) => panic!("accept Unix request: {error}"),
}
}
});
let probe = r#"
import errno, socket, sys
path, port, mode = sys.argv[1:]
try:
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
except OSError as error:
assert mode == 'deny' and error.errno == errno.EPERM, error
else:
assert mode == 'allow', 'Unix socket unexpectedly allowed'
with client:
client.settimeout(2)
client.connect(path)
client.sendall(b'ping')
assert client.recv(4, socket.MSG_WAITALL) == b'pong'
for address in [('127.0.0.2', int(port)), ('192.0.2.1', 80)]:
try:
connection = socket.create_connection(address, timeout=0.5)
except OSError:
pass
else:
connection.close()
raise AssertionError(('direct network unexpectedly allowed', address))
for family in (socket.AF_NETLINK, getattr(socket, 'AF_VSOCK', 40)):
try:
unexpected = socket.socket(family, socket.SOCK_STREAM)
except OSError as error:
assert error.errno == errno.EPERM, (family, error)
else:
unexpected.close()
raise AssertionError(('socket family unexpectedly allowed', family))
"#;
let cwd = std::env::current_dir().expect("current directory");
for (label, dangerously_allow_all_unix_sockets, allow_path) in [
("default denial", false, false),
("path grant stays restricted", false, true),
("explicit allow-all", true, false),
] {
let mut env = create_env_from_core_vars();
strip_proxy_env(&mut env);
let mut config = NetworkProxyConfig {
enabled: true,
proxy_url: "http://127.0.0.1:9".to_string(),
dangerously_allow_all_unix_sockets,
..Default::default()
};
if allow_path {
config.set_allow_unix_sockets(vec![socket_path.to_string_lossy().into_owned()]);
}
let state = build_config_state(config, NetworkProxyConstraints::default())
.expect("valid managed network configuration");
let network = NetworkProxy::builder()
.state(Arc::new(NetworkProxyState::with_reloader(
state.clone(),
Arc::new(TestConfigReloader(state)),
)))
.managed_by_codex(/*managed_by_codex*/ false)
.build()
.await
.expect("build managed network proxy");
let prepared = network
.prepare_for_optional_environment(env, /*environment_id*/ None)
.expect("prepare managed network policy");
let mut command = Command::new(env!("CARGO_BIN_EXE_codex-linux-sandbox"));
command
.arg("--sandbox-policy-cwd")
.arg(&cwd)
.arg("--permission-profile")
.arg(serde_json::to_string(&PermissionProfile::Disabled).unwrap())
.arg("--managed-network")
.arg(serde_json::to_string(&prepared.sandbox_context).unwrap())
.args(["--", "python3", "-c", probe])
.arg(&socket_path)
.arg(tcp_addr.port().to_string())
.arg(if dangerously_allow_all_unix_sockets {
"allow"
} else {
"deny"
})
.env_clear()
.envs(prepared.env)
.kill_on_drop(true);
let output = tokio::time::timeout(OPERATION_TIMEOUT, command.output())
.await
.expect("Unix socket probe timed out")
.expect("Unix socket probe should execute");
assert!(
output.status.success(),
"{label}; stderr={}",
String::from_utf8_lossy(&output.stderr)
);
}
server.join().expect("Unix server should finish");
}

View File

@@ -411,6 +411,7 @@ fn managed_network_allows_authorized_loopback_without_lan_or_dns_access() -> Res
let proxy = ManagedNetworkSandboxContext {
loopback_ports: vec![43123, 43124],
allow_local_binding: true,
..Default::default()
};
let mut env = HashMap::new();
create_command_args(CreateMxcCommandArgsParams {
@@ -466,6 +467,7 @@ fn invalid_managed_network_is_rejected_at_both_boundaries() -> Result<()> {
let proxy = ManagedNetworkSandboxContext {
loopback_ports,
allow_local_binding,
..Default::default()
};
let mut env = HashMap::new();
assert!(

View File

@@ -483,6 +483,12 @@ pub struct ManagedNetworkSandboxContext {
/// Whether the command may bind local sockets and exchange loopback traffic.
#[serde(default)]
pub allow_local_binding: bool,
/// Unix-domain socket paths allowed by the effective managed-network policy.
#[serde(default)]
pub allow_unix_sockets: Vec<String>,
/// Whether the effective policy permits connections to all Unix-domain sockets.
#[serde(default)]
pub dangerously_allow_all_unix_sockets: bool,
}
/// Environment-specific managed-network settings prepared for one command launch.
@@ -1133,6 +1139,9 @@ impl NetworkProxy {
sandbox_context: ManagedNetworkSandboxContext {
loopback_ports,
allow_local_binding: runtime_settings.allow_local_binding,
allow_unix_sockets: runtime_settings.allow_unix_sockets.to_vec(),
dangerously_allow_all_unix_sockets: runtime_settings
.dangerously_allow_all_unix_sockets,
},
}
}
@@ -2109,6 +2118,60 @@ mod tests {
);
}
#[tokio::test]
async fn prepare_for_optional_environment_preserves_effective_unix_socket_permissions()
-> Result<()> {
let mut config = NetworkProxyConfig {
enabled: true,
proxy_url: "http://127.0.0.1:43128".to_string(),
socks_url: "http://127.0.0.1:48081".to_string(),
allow_local_binding: true,
unix_sockets: Some(crate::config::NetworkUnixSocketPermissions {
entries: [
(
"/tmp/allowed.sock".to_string(),
crate::config::NetworkUnixSocketPermission::Allow,
),
(
"/tmp/denied.sock".to_string(),
crate::config::NetworkUnixSocketPermission::Deny,
),
]
.into_iter()
.collect(),
}),
..NetworkProxyConfig::default()
};
let proxy = NetworkProxy::builder()
.state(Arc::new(network_proxy_state_for_policy(config.clone())))
.managed_by_codex(/*managed_by_codex*/ false)
.build()
.await?;
for allow_all in [false, true] {
config.dangerously_allow_all_unix_sockets = allow_all;
let replacement = crate::state::build_config_state(config.clone(), Default::default())?;
proxy.replace_config_state(replacement).await?;
let prepared = proxy
.prepare_for_optional_environment(HashMap::new(), /*environment_id*/ None)?;
assert_eq!(
prepared.sandbox_context,
ManagedNetworkSandboxContext {
loopback_ports: vec![43128, 48081],
allow_local_binding: true,
allow_unix_sockets: if cfg!(target_os = "windows") {
Vec::new()
} else {
vec!["/tmp/allowed.sock".to_string()]
},
dangerously_allow_all_unix_sockets: !cfg!(target_os = "windows") && allow_all,
}
);
}
Ok(())
}
#[tokio::test]
async fn prepare_for_environment_keeps_env_and_sandbox_ports_in_sync() -> Result<()> {
#[cfg(target_os = "windows")]
@@ -2173,6 +2236,7 @@ mod tests {
ManagedNetworkSandboxContext {
loopback_ports: expected_ports,
allow_local_binding: false,
..ManagedNetworkSandboxContext::default()
}
);
}

View File

@@ -68,6 +68,7 @@ async fn dedicated_listeners_preserve_http_and_socks_policy_without_restricted_t
ManagedNetworkSandboxContext {
loopback_ports: ports,
allow_local_binding: false,
..ManagedNetworkSandboxContext::default()
}
);
assert_eq!(

View File

@@ -1,3 +1,4 @@
use codex_network_proxy::ManagedNetworkSandboxContext;
use codex_protocol::models::PermissionProfile;
use std::path::Path;
@@ -15,18 +16,20 @@ pub fn allow_network_for_proxy(enforce_managed_network: bool) -> bool {
/// Converts the permission profile into the CLI invocation for
/// `codex-linux-sandbox`.
///
/// A managed context selects proxy isolation; a default context keeps its
/// restrictive policy when an older caller supplies no additional details.
///
/// The helper performs the actual sandboxing (bubblewrap by default + seccomp)
/// after parsing these arguments. The profile JSON flag is emitted before
/// helper feature flags so the argv order matches the helper's CLI shape. See
/// `docs/linux_sandbox.md` for the Linux semantics.
#[allow(clippy::too_many_arguments)]
pub fn create_linux_sandbox_command_args_for_permission_profile(
command: Vec<String>,
command_cwd: &Path,
permission_profile: &PermissionProfile,
sandbox_policy_cwd: &Path,
use_legacy_landlock: bool,
allow_network_for_proxy: bool,
managed_network: Option<&ManagedNetworkSandboxContext>,
) -> Vec<String> {
let permission_profile_json = serde_json::to_string(permission_profile)
.unwrap_or_else(|err| panic!("failed to serialize permission profile: {err}"));
@@ -48,11 +51,15 @@ pub fn create_linux_sandbox_command_args_for_permission_profile(
permission_profile_json,
];
// Proxy-only networking requires bubblewrap's isolated network namespace.
if use_legacy_landlock && !allow_network_for_proxy {
if use_legacy_landlock && managed_network.is_none() {
linux_cmd.push("--use-legacy-landlock".to_string());
}
if allow_network_for_proxy {
linux_cmd.push("--allow-network-for-proxy".to_string());
if let Some(managed_network) = managed_network {
linux_cmd.push("--managed-network".to_string());
linux_cmd.push(
serde_json::to_string(managed_network)
.unwrap_or_else(|err| panic!("failed to serialize managed network context: {err}")),
);
}
linux_cmd.push("--".to_string());
linux_cmd.extend(command);
@@ -89,7 +96,8 @@ fn create_linux_sandbox_command_args(
linux_cmd.push("--use-legacy-landlock".to_string());
}
if allow_network_for_proxy {
linux_cmd.push("--allow-network-for-proxy".to_string());
linux_cmd.push("--managed-network".to_string());
linux_cmd.push("{}".to_string());
}
// Separator so that command arguments starting with `-` are not parsed as

View File

@@ -33,7 +33,7 @@ fn legacy_landlock_flag_is_included_when_requested() {
}
#[test]
fn proxy_flag_takes_precedence_over_legacy_landlock() {
fn managed_context_takes_precedence_over_legacy_landlock() {
let command = vec!["/bin/true".to_string()];
let command_cwd = Path::new("/tmp/link");
let cwd = Path::new("/tmp");
@@ -45,12 +45,9 @@ fn proxy_flag_takes_precedence_over_legacy_landlock() {
&permission_profile,
cwd,
/*use_legacy_landlock*/ true,
/*allow_network_for_proxy*/ true,
);
assert_eq!(
args.contains(&"--allow-network-for-proxy".to_string()),
true
Some(&ManagedNetworkSandboxContext::default()),
);
assert_eq!(args.contains(&"--managed-network".to_string()), true);
assert_eq!(args.contains(&"--use-legacy-landlock".to_string()), false);
}
@@ -67,7 +64,7 @@ fn permission_profile_flag_is_included() {
&permission_profile,
cwd,
/*use_legacy_landlock*/ true,
/*allow_network_for_proxy*/ false,
/*managed_network*/ None,
);
assert_eq!(

View File

@@ -3,7 +3,6 @@ use crate::bwrap::WSL1_BWRAP_WARNING;
#[cfg(target_os = "linux")]
use crate::bwrap::is_wsl1;
use crate::landlock::CODEX_LINUX_SANDBOX_ARG0;
use crate::landlock::allow_network_for_proxy;
use crate::landlock::create_linux_sandbox_command_args_for_permission_profile;
use crate::policy_transforms::effective_permission_profile;
use crate::policy_transforms::should_require_platform_sandbox;
@@ -496,14 +495,30 @@ impl SandboxManager {
let pending = pending_sandboxed_request?;
let exe =
sandbox_exe.ok_or(SandboxTransformError::MissingLinuxSandboxExecutable)?;
let allow_proxy_network = allow_network_for_proxy(enforce_managed_network);
if enforce_managed_network
&& command.managed_network.is_none()
&& let Some(network) = network
{
let prepared = network
.prepare_for_optional_environment(
std::mem::take(&mut command.env),
environment_id,
)
.map_err(|err| {
SandboxTransformError::EnvironmentNetworkProxy(err.to_string())
})?;
command.env = prepared.env;
command.managed_network = Some(prepared.sandbox_context);
}
let managed_network =
enforce_managed_network.then(|| command.managed_network.unwrap_or_default());
#[cfg(target_os = "linux")]
ensure_linux_bubblewrap_is_supported(
&pending
.effective_permission_profile
.file_system_sandbox_policy(),
use_legacy_landlock,
allow_proxy_network,
managed_network.is_some(),
is_wsl1(),
)?;
let mut args = create_linux_sandbox_command_args_for_permission_profile(
@@ -512,7 +527,7 @@ impl SandboxManager {
&pending.effective_permission_profile,
pending.native_sandbox_policy_cwd.as_path(),
use_legacy_landlock,
allow_proxy_network,
managed_network.as_ref(),
);
let mut full_command = Vec::with_capacity(1 + args.len());
full_command.push(os_string_to_command_component(exe.as_os_str().to_owned()));

View File

@@ -530,6 +530,131 @@ fn transform_linux_seccomp_uses_helper_alias_when_launcher_is_not_helper_path()
assert_eq!(exec_request.arg0, Some("codex-linux-sandbox".to_string()));
}
#[cfg(unix)]
#[tokio::test]
async fn linux_unix_socket_grant_uses_effective_managed_policy() -> anyhow::Result<()> {
use codex_network_proxy::ConfigReloader;
use codex_network_proxy::ConfigReloaderFuture;
use codex_network_proxy::ConfigState;
use codex_network_proxy::ManagedNetworkSandboxContext;
use codex_network_proxy::NetworkProxy;
use codex_network_proxy::NetworkProxyConfig;
use codex_network_proxy::NetworkProxyConstraints;
use codex_network_proxy::NetworkProxyState;
use codex_network_proxy::build_config_state;
use std::sync::Arc;
struct TestConfigReloader;
impl ConfigReloader for TestConfigReloader {
fn source_label(&self) -> String {
"sandbox manager test config".to_string()
}
fn maybe_reload(&self) -> ConfigReloaderFuture<'_, Option<ConfigState>> {
Box::pin(async { Ok(None) })
}
fn reload_now(&self) -> ConfigReloaderFuture<'_, ConfigState> {
Box::pin(async { Err(anyhow::anyhow!("test config cannot reload")) })
}
}
let state = build_config_state(
NetworkProxyConfig {
enabled: true,
dangerously_allow_all_unix_sockets: true,
..Default::default()
},
NetworkProxyConstraints::default(),
)?;
let network = NetworkProxy::builder()
.state(Arc::new(NetworkProxyState::with_reloader(
state,
Arc::new(TestConfigReloader),
)))
.managed_by_codex(/*managed_by_codex*/ false)
.build()
.await?;
let prepared =
network.prepare_for_optional_environment(HashMap::new(), /*environment_id*/ None)?;
let allow_all = prepared.sandbox_context;
let path_only = ManagedNetworkSandboxContext {
allow_unix_sockets: vec!["/tmp/daemon.sock".to_string()],
..Default::default()
};
let denied = ManagedNetworkSandboxContext::default();
let cwd = AbsolutePathBuf::current_dir()?;
let cwd_uri = PathUri::from_abs_path(&cwd);
let manager = SandboxManager::new();
for (label, context, live_proxy, enforce_managed_network, expected) in [
(
"missing policy stays managed with restrictive defaults",
None,
false,
true,
Some(denied.clone()),
),
(
"path grant stays restricted",
Some(path_only.clone()),
false,
true,
Some(path_only),
),
(
"prepared allow-all",
Some(allow_all.clone()),
false,
true,
Some(allow_all.clone()),
),
(
"prepared denial overrides live allow-all",
Some(denied.clone()),
true,
true,
Some(denied),
),
(
"live proxy fallback",
None,
true,
true,
Some(allow_all.clone()),
),
("no managed network", Some(allow_all), true, false, None),
] {
let request = manager.transform(SandboxTransformRequest {
command: SandboxCommand {
program: "true".into(),
args: Vec::new(),
cwd: cwd_uri.clone(),
env: HashMap::new(),
managed_network: context,
additional_permissions: None,
},
permissions: &PermissionProfile::Disabled,
sandbox: SandboxType::LinuxSeccomp,
enforce_managed_network,
environment_id: None,
network: live_proxy.then_some(&network),
sandbox_policy_cwd: &cwd_uri,
sandbox_exe: Some(std::path::Path::new("/tmp/codex-linux-sandbox")),
use_legacy_landlock: false,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
})?;
let transported_context = request
.command
.windows(2)
.find(|args| args[0] == "--managed-network")
.map(|args| serde_json::from_str::<ManagedNetworkSandboxContext>(&args[1]))
.transpose()?;
assert_eq!(transported_context, expected, "{label}");
}
Ok(())
}
#[cfg(target_os = "windows")]
#[test]
fn transform_for_direct_spawn_windows_preserves_only_wrapper_setup_environment() {

View File

@@ -777,6 +777,7 @@ fn prepared_managed_network_context_allows_only_its_proxy_ports() {
let managed_network = ManagedNetworkSandboxContext {
loopback_ports: vec![43123, 48081],
allow_local_binding: false,
..Default::default()
};
let args = create_seatbelt_command_args(CreateSeatbeltCommandArgsParams {
command: vec!["/bin/true".to_string()],