Files
codex/codex-rs/linux-sandbox/src/seccomp.rs
T
viyatb-oai 7194b21776 Add opt-in transparent TCP routing for Linux sandboxes (#61372)
## Why

Native TCP clients need to reach managed network destinations without implementing proxy support themselves.

## What changed

- Add `--transparent-tcp` to the Linux sandbox command, using a bundled TCP stack, private TUN interface, and DNS stub to route native IPv4 and IPv6 connections through the attributed managed SOCKS proxy.
- Preserve original hostnames for proxy policy checks and cancel pending approval connections when their native sockets close.
- Expose `--linux-sandbox-transparent-tcp` and `--tcp-upstream-proxy` on `codex exec-server` for transparent routing and a mandatory HTTP CONNECT next hop without direct fallback.
- Require private network and PID namespaces, private procfs, system `bwrap`, and access to `/dev/net/tun`; reject incompatible launcher options and untrusted proxy ports.

## Testing

Add integration coverage for native hostname and IPv6 connections, SOCKS attribution, pending connection cancellation, policy denials, and filesystem and host-loopback isolation. Extend launcher and executor tests for option validation and managed-policy propagation.

GitOrigin-RevId: 96bd7726e5369efceb65320face56aa85ae3deb8
2026-11-13 20:24:37 +00:00

378 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! In-process Linux sandbox primitives: `no_new_privs` and seccomp.
//!
//! Filesystem restrictions are enforced by bubblewrap in `linux_run_main`.
//! The legacy Landlock filesystem implementation is retained here, but the
//! helper rejects legacy mode for filesystem-restricted policies.
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;
use codex_protocol::protocol::FileSystemSandboxPolicy;
use codex_protocol::protocol::NetworkSandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use landlock::ABI;
#[allow(unused_imports)]
use landlock::Access;
use landlock::AccessFs;
use landlock::CompatLevel;
use landlock::Compatible;
use landlock::Ruleset;
use landlock::RulesetAttr;
use landlock::RulesetCreatedAttr;
use seccompiler::BpfProgram;
use seccompiler::SeccompAction;
use seccompiler::SeccompCmpArgLen;
use seccompiler::SeccompCmpOp;
use seccompiler::SeccompCondition;
use seccompiler::SeccompFilter;
use seccompiler::SeccompRule;
use seccompiler::TargetArch;
use seccompiler::apply_filter;
/// Apply sandbox policies inside this thread so only the child inherits
/// them, not the entire CLI process.
///
/// This function is responsible for:
/// - enabling `PR_SET_NO_NEW_PRIVS` when restrictions apply, and
/// - installing seccomp restrictions for network isolation and VM sockets.
///
/// Filesystem restrictions are normally handled by bubblewrap. The legacy
/// Landlock implementation is only selected by `apply_landlock_fs`.
pub(crate) fn apply_sandbox_policies_to_current_thread(
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
cwd: &Path,
apply_landlock_fs: bool,
managed_network: Option<&ManagedNetworkSandboxContext>,
proxy_routing: Option<ProxyRouting>,
) -> Result<()> {
let network_seccomp_mode = network_seccomp_mode(
network_sandbox_policy,
managed_network.is_some(),
proxy_routing,
)
.or_else(|| {
// VM sockets can reach host services outside the filesystem sandbox.
// In WSL2 they also allow Windows process launch through an alias of
// the interop socket, even when /run/WSL is masked. Keep ordinary
// network access while denying that host bridge.
(!file_system_sandbox_policy.has_full_disk_write_access())
.then_some(NetworkSeccompMode::VmSocketRestricted)
});
// `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 network_seccomp_mode.is_some()
|| (apply_landlock_fs && !file_system_sandbox_policy.has_full_disk_write_access())
{
set_no_new_privs()?;
}
if let Some(mode) = network_seccomp_mode {
install_network_seccomp_filter_on_current_thread(mode, managed_network)?;
}
if apply_landlock_fs && !file_system_sandbox_policy.has_full_disk_write_access() {
if !file_system_sandbox_policy.has_full_disk_read_access() {
return Err(CodexErr::UnsupportedOperation(
"Restricted read-only access is not supported by the legacy Linux Landlock filesystem backend."
.to_string(),
));
}
let writable_roots = file_system_sandbox_policy
.get_writable_roots_with_cwd(cwd)
.into_iter()
.map(|writable_root| writable_root.root)
.collect();
install_filesystem_landlock_rules_on_current_thread(writable_roots)?;
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum NetworkSeccompMode {
Restricted,
ProxyRouted(ProxyRouting),
VmSocketRestricted,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ProxyRouting {
Explicit,
TransparentTcp,
}
fn should_install_network_seccomp(
network_sandbox_policy: NetworkSandboxPolicy,
allow_network_for_proxy: bool,
) -> bool {
// Managed-network sessions should remain fail-closed even for policies that
// would normally grant full network access (for example, DangerFullAccess).
!network_sandbox_policy.is_enabled() || allow_network_for_proxy
}
fn network_seccomp_mode(
network_sandbox_policy: NetworkSandboxPolicy,
allow_network_for_proxy: bool,
proxy_routed_network: Option<ProxyRouting>,
) -> Option<NetworkSeccompMode> {
if !should_install_network_seccomp(network_sandbox_policy, allow_network_for_proxy) {
None
} else if let Some(routing) = proxy_routed_network {
Some(NetworkSeccompMode::ProxyRouted(routing))
} 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) };
if result != 0 {
return Err(std::io::Error::last_os_error().into());
}
Ok(())
}
/// Installs Landlock file-system rules on the current thread allowing read
/// access to the entire file-system while restricting write access to
/// `/dev/null` and the provided list of `writable_roots`.
///
/// # Errors
/// Returns [`CodexErr::Sandbox`] variants when the ruleset fails to apply.
///
/// Retained for the legacy pipeline. The helper currently rejects legacy mode
/// for filesystem-restricted policies, so filesystem sandboxing uses bubblewrap.
fn install_filesystem_landlock_rules_on_current_thread(
writable_roots: Vec<AbsolutePathBuf>,
) -> Result<()> {
let abi = ABI::V5;
let access_rw = AccessFs::from_all(abi);
let access_ro = AccessFs::from_read(abi);
let mut ruleset = Ruleset::default()
.set_compatibility(CompatLevel::BestEffort)
.handle_access(access_rw)?
.create()?
.add_rules(landlock::path_beneath_rules(&["/"], access_ro))?
.add_rules(landlock::path_beneath_rules(&["/dev/null"], access_rw))?
.set_no_new_privs(true);
if !writable_roots.is_empty() {
ruleset = ruleset.add_rules(landlock::path_beneath_rules(&writable_roots, access_rw))?;
}
let status = ruleset.restrict_self()?;
if status.ruleset == landlock::RulesetStatus::NotEnforced {
return Err(CodexErr::Sandbox(SandboxErr::LandlockRestrict));
}
Ok(())
}
/// 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(
mode: NetworkSeccompMode,
managed_network: Option<&ManagedNetworkSandboxContext>,
) -> std::result::Result<(), SandboxErr> {
fn deny_syscall(rules: &mut BTreeMap<i64, Vec<SeccompRule>>, nr: i64) {
add_syscall_rules(rules, nr, vec![]); // empty rule vec = unconditional match
}
fn add_syscall_rules(
rules: &mut BTreeMap<i64, Vec<SeccompRule>>,
nr: i64,
syscall_rules: Vec<SeccompRule>,
) {
#[cfg(target_arch = "x86_64")]
{
const X32_SYSCALL_BIT: i64 = 0x4000_0000;
// x32 shares AUDIT_ARCH_X86_64, so the architecture check alone
// does not enforce these restrictions for x32 callers.
rules.insert(nr | X32_SYSCALL_BIT, syscall_rules.clone());
// These restricted calls have separate x32 syscall numbers.
let x32_nr = match nr {
libc::SYS_ptrace => Some(521),
libc::SYS_recvmmsg => Some(537),
libc::SYS_sendmmsg => Some(538),
libc::SYS_process_vm_readv => Some(539),
libc::SYS_process_vm_writev => Some(540),
libc::SYS_setsockopt => Some(541),
libc::SYS_getsockopt => Some(542),
_ => None,
};
if let Some(x32_nr) = x32_nr {
rules.insert(x32_nr | X32_SYSCALL_BIT, syscall_rules.clone());
// Before Linux 5.4, both the unflagged x32 number and the
// flagged native number above could also reach these calls.
rules.insert(x32_nr, syscall_rules.clone());
}
}
rules.insert(nr, syscall_rules);
}
// Build rule map.
let mut rules: BTreeMap<i64, Vec<SeccompRule>> = BTreeMap::new();
if mode != NetworkSeccompMode::VmSocketRestricted {
deny_syscall(&mut rules, libc::SYS_ptrace);
deny_syscall(&mut rules, libc::SYS_process_vm_readv);
deny_syscall(&mut rules, libc::SYS_process_vm_writev);
// request_key can invoke host helpers that perform network requests
// outside the sandbox's network namespace and proxy policy.
deny_syscall(&mut rules, libc::SYS_request_key);
}
// io_uring can create AF_VSOCK sockets without a socket() syscall, so
// keep it unavailable in every mode with socket-family restrictions.
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);
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,
)?])?;
add_syscall_rules(&mut rules, libc::SYS_socket, vec![unix_only_rule.clone()]);
add_syscall_rules(&mut rules, libc::SYS_socketpair, vec![unix_only_rule]);
}
NetworkSeccompMode::ProxyRouted(routing) => {
// In proxy-routed mode we allow IP sockets in the isolated
// namespace (used to reach the local TCP bridge). Standalone Unix
// sockets require an explicit managed-policy grant. Transparent TCP
// also permits route enumeration in its private network namespace.
let mut denied_socket_conditions = 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,
)?,
];
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 mut socket_rules = Vec::new();
if routing == ProxyRouting::TransparentTcp {
// Native clients use getifaddrs to enumerate their interfaces. Route
// mutation requires CAP_NET_ADMIN in the parent user namespace that
// owns the TUN. The workload has no capabilities there.
denied_socket_conditions.push(SeccompCondition::new(
0,
SeccompCmpArgLen::Dword,
SeccompCmpOp::Ne,
libc::AF_NETLINK as u64,
)?);
socket_rules.push(SeccompRule::new(vec![
SeccompCondition::new(
0,
SeccompCmpArgLen::Dword,
SeccompCmpOp::Eq,
libc::AF_NETLINK as u64,
)?,
SeccompCondition::new(
2,
SeccompCmpArgLen::Dword,
SeccompCmpOp::Ne,
libc::NETLINK_ROUTE as u64,
)?,
])?);
}
socket_rules.push(SeccompRule::new(denied_socket_conditions)?);
let deny_non_unix_socketpair = SeccompRule::new(vec![SeccompCondition::new(
0,
SeccompCmpArgLen::Dword,
SeccompCmpOp::Ne,
libc::AF_UNIX as u64,
)?])?;
add_syscall_rules(&mut rules, libc::SYS_socket, socket_rules);
add_syscall_rules(
&mut rules,
libc::SYS_socketpair,
vec![deny_non_unix_socketpair],
);
}
NetworkSeccompMode::VmSocketRestricted => {
let deny_vsock = SeccompRule::new(vec![SeccompCondition::new(
0,
SeccompCmpArgLen::Dword,
SeccompCmpOp::Eq,
libc::AF_VSOCK as u64,
)?])?;
add_syscall_rules(&mut rules, libc::SYS_socket, vec![deny_vsock.clone()]);
add_syscall_rules(&mut rules, libc::SYS_socketpair, vec![deny_vsock]);
}
}
let filter = SeccompFilter::new(
rules,
SeccompAction::Allow, // default – allow
SeccompAction::Errno(libc::EPERM as u32), // when rule matches – return EPERM
if cfg!(target_arch = "x86_64") {
TargetArch::x86_64
} else if cfg!(target_arch = "aarch64") {
TargetArch::aarch64
} else {
unimplemented!("unsupported architecture for seccomp filter");
},
)?;
let prog: BpfProgram = filter.try_into()?;
apply_filter(&prog)?;
Ok(())
}
#[cfg(test)]
#[path = "seccomp_tests.rs"]
mod tests;