From 7194b2177643f3c639cfc5b29f6398c545b0e7e2 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 13 Nov 2026 18:29:54 +0000 Subject: [PATCH] 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 --- MODULE.bazel | 4 + MODULE.bazel.lock | 4 + codex-rs/Cargo.lock | 44 +++ codex-rs/cli/src/debug_sandbox.rs | 22 +- codex-rs/cli/src/lib.rs | 4 + ..._server_help_documents_remote_options.snap | 7 + codex-rs/deny.toml | 4 +- codex-rs/exec-server-cli/src/command.rs | 10 + ...alone_help_documents_executor_options.snap | 9 +- codex-rs/exec-server/README.md | 13 + codex-rs/exec-server/src/lib.rs | 1 + codex-rs/exec-server/src/process_sandbox.rs | 28 +- .../exec-server/src/process_sandbox_tests.rs | 20 + codex-rs/exec-server/src/runtime_options.rs | 11 + codex-rs/linux-sandbox/BUILD.bazel | 18 + codex-rs/linux-sandbox/Cargo.toml | 12 +- codex-rs/linux-sandbox/README.md | 27 +- .../linux-sandbox/managed_tcp_test_bwrap.bzl | 13 + codex-rs/linux-sandbox/src/bwrap.rs | 6 +- codex-rs/linux-sandbox/src/launcher.rs | 4 + codex-rs/linux-sandbox/src/lib.rs | 8 +- codex-rs/linux-sandbox/src/linux_run_main.rs | 123 +++++-- codex-rs/linux-sandbox/src/managed_tcp.rs | 132 +++++-- codex-rs/linux-sandbox/src/managed_tcp_dns.rs | 60 +++ .../linux-sandbox/src/managed_tcp_pending.rs | 140 +++++++ .../src/managed_tcp_relay_tests.rs | 58 +-- .../linux-sandbox/src/managed_tcp_stack.rs | 344 ++++++++++++++++++ codex-rs/linux-sandbox/src/proxy_routing.rs | 42 ++- .../linux-sandbox/src/proxy_routing_tests.rs | 8 +- codex-rs/linux-sandbox/src/seccomp.rs | 54 ++- codex-rs/linux-sandbox/src/seccomp_tests.rs | 26 +- codex-rs/linux-sandbox/src/tcp_namespace.rs | 133 +++++++ codex-rs/linux-sandbox/src/tcp_net_setup.rs | 134 +++++++ .../tests/suite/managed_proxy.rs | 35 +- .../linux-sandbox/tests/suite/managed_tcp.rs | 213 +++++++++++ codex-rs/network-proxy/src/mitm_tests.rs | 9 +- codex-rs/network-proxy/src/proxy.rs | 8 + codex-rs/network-proxy/src/socks5.rs | 5 + codex-rs/sandboxing/src/manager.rs | 10 + codex-rs/sandboxing/src/manager_tests.rs | 22 +- 40 files changed, 1637 insertions(+), 188 deletions(-) create mode 100644 codex-rs/linux-sandbox/managed_tcp_test_bwrap.bzl create mode 100644 codex-rs/linux-sandbox/src/managed_tcp_pending.rs create mode 100644 codex-rs/linux-sandbox/src/managed_tcp_stack.rs create mode 100644 codex-rs/linux-sandbox/src/tcp_namespace.rs create mode 100644 codex-rs/linux-sandbox/src/tcp_net_setup.rs create mode 100644 codex-rs/linux-sandbox/tests/suite/managed_tcp.rs diff --git a/MODULE.bazel b/MODULE.bazel index 6569b8ea39..e7eb55b07b 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -537,6 +537,10 @@ crate.annotation( http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") +managed_tcp_test_bwrap = use_repo_rule("//codex-rs/linux-sandbox:managed_tcp_test_bwrap.bzl", "managed_tcp_test_bwrap") + +managed_tcp_test_bwrap(name = "managed_tcp_test_bubblewrap_noble_amd64") + # Cargo keeps vendored OpenSSL for musl; Bazel already supplies its own OpenSSL. # Replace this crate's generated build, not Cargo's feature resolution. http_archive( diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 7f9e0b739b..9629e951d6 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -1299,6 +1299,7 @@ "errno_0.3.14": "{\"dependencies\":[{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(target_os=\\\"hermit\\\")\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(target_os=\\\"wasi\\\")\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Diagnostics_Debug\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"libc/std\"]}}", "error-code_3.3.2": "{\"dependencies\":[],\"features\":{\"std\":[]}}", "etcetera_0.11.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Com\",\"Win32_UI_Shell\"],\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{}}", + "etherparse_0.21.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"arrayvec\",\"req\":\"^0.7.2\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.4.0\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\",\"arrayvec/std\"]}}", "euclid_0.22.14": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"bytemuck\",\"optional\":true,\"req\":\"^1.9\"},{\"default_features\":false,\"name\":\"malloc_size_of\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"mint\",\"optional\":true,\"req\":\"^0.5.1\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2.15\"},{\"default_features\":false,\"features\":[\"serde_derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"libm\":[\"num-traits/libm\"],\"std\":[\"num-traits/std\"],\"unstable\":[]}}", "event-listener-strategy_0.5.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"event-listener\",\"req\":\"^5.0.0\"},{\"kind\":\"dev\",\"name\":\"futures-lite\",\"req\":\"^2.0.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.12\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.37\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"}],\"features\":{\"default\":[\"std\"],\"loom\":[\"event-listener/loom\"],\"portable-atomic\":[\"event-listener/portable-atomic\"],\"std\":[\"event-listener/std\"]}}", "event-listener_5.4.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"concurrent-queue\",\"req\":\"^2.4.0\"},{\"default_features\":false,\"features\":[\"cargo_bench_support\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"default_features\":false,\"name\":\"critical-section\",\"optional\":true,\"req\":\"^1.2.0\"},{\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"critical-section\",\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"futures-lite\",\"req\":\"^2.0.0\"},{\"name\":\"loom\",\"optional\":true,\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"name\":\"parking\",\"optional\":true,\"req\":\"^2.0.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.12\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"portable-atomic-util\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"portable_atomic_crate\",\"optional\":true,\"package\":\"portable-atomic\",\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"try-lock\",\"req\":\"^0.2.5\"},{\"kind\":\"dev\",\"name\":\"waker-fn\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"}],\"features\":{\"default\":[\"std\"],\"loom\":[\"concurrent-queue/loom\",\"parking?/loom\",\"dep:loom\"],\"portable-atomic\":[\"portable-atomic-util\",\"portable_atomic_crate\",\"concurrent-queue/portable-atomic\"],\"std\":[\"concurrent-queue/std\",\"parking\"]}}", @@ -1479,6 +1480,7 @@ "headers_0.4.1": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.22\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"headers-core\",\"req\":\"^0.3\"},{\"name\":\"http\",\"req\":\"^1.0.0\"},{\"name\":\"httpdate\",\"req\":\"^1\"},{\"name\":\"mime\",\"req\":\"^0.3.14\"},{\"name\":\"sha1\",\"req\":\"^0.10\"}],\"features\":{\"nightly\":[]}}", "heapless_0.7.17": "{\"dependencies\":[{\"name\":\"atomic-polyfill\",\"req\":\"^1\",\"target\":\"riscv32i-unknown-none-elf\"},{\"name\":\"atomic-polyfill\",\"req\":\"^1\",\"target\":\"riscv32imc-unknown-none-elf\"},{\"name\":\"atomic-polyfill\",\"req\":\"^1\",\"target\":\"xtensa-esp32s2-none-elf\"},{\"name\":\"atomic-polyfill\",\"optional\":true,\"req\":\"^1\",\"target\":\"cfg(target_arch = \\\"avr\\\")\"},{\"name\":\"atomic-polyfill\",\"optional\":true,\"req\":\"^1\",\"target\":\"thumbv6m-none-eabi\"},{\"name\":\"defmt\",\"optional\":true,\"req\":\">=0.2.0, <0.4\"},{\"name\":\"hash32\",\"req\":\"^0.2.1\"},{\"kind\":\"build\",\"name\":\"rustc_version\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"spin\",\"req\":\"^0.9.2\",\"target\":\"cfg(target_arch = \\\"x86_64\\\")\"},{\"default_features\":false,\"name\":\"stable_deref_trait\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"ufmt\",\"req\":\"^0.1\"},{\"name\":\"ufmt-write\",\"optional\":true,\"req\":\"^0.1\"}],\"features\":{\"__trybuild\":[],\"cas\":[\"atomic-polyfill\"],\"default\":[\"cas\"],\"defmt-impl\":[\"defmt\"],\"mpmc_large\":[],\"ufmt-impl\":[\"ufmt-write\"],\"x86-sync-pool\":[]}}", "heapless_0.8.0": "{\"dependencies\":[{\"name\":\"defmt\",\"optional\":true,\"req\":\">=0.2.0, <0.4\"},{\"name\":\"hash32\",\"req\":\"^0.3.0\"},{\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"stable_deref_trait\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"ufmt\",\"req\":\"^0.2\"},{\"name\":\"ufmt-write\",\"optional\":true,\"req\":\"^0.1\"}],\"features\":{\"defmt-03\":[\"dep:defmt\"],\"mpmc_large\":[],\"portable-atomic\":[\"dep:portable-atomic\"],\"portable-atomic-critical-section\":[\"dep:portable-atomic\",\"portable-atomic\",\"portable-atomic?/critical-section\"],\"portable-atomic-unsafe-assume-single-core\":[\"dep:portable-atomic\",\"portable-atomic\",\"portable-atomic?/unsafe-assume-single-core\"],\"serde\":[\"dep:serde\"],\"ufmt\":[\"dep:ufmt-write\"]}}", + "heapless_0.9.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"critical-section\",\"req\":\"^1.1\"},{\"name\":\"defmt\",\"optional\":true,\"req\":\"^1.0.1\"},{\"name\":\"embedded-io\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"hash32\",\"req\":\"^0.3.0\"},{\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"stable_deref_trait\",\"req\":\"^1\",\"target\":\"cfg(any(target_arch = \\\"arm\\\", target_pointer_width = \\\"32\\\", target_pointer_width = \\\"64\\\"))\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"name\":\"ufmt\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"ufmt-write\",\"optional\":true,\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.8\"}],\"features\":{\"alloc\":[],\"bytes\":[\"dep:bytes\"],\"defmt\":[\"dep:defmt\"],\"embedded-io-v0.7\":[\"dep:embedded-io\"],\"mpmc_large\":[],\"nightly\":[],\"portable-atomic\":[\"dep:portable-atomic\"],\"portable-atomic-critical-section\":[\"dep:portable-atomic\",\"portable-atomic\",\"portable-atomic?/critical-section\"],\"portable-atomic-unsafe-assume-single-core\":[\"dep:portable-atomic\",\"portable-atomic\",\"portable-atomic?/unsafe-assume-single-core\"],\"serde\":[\"dep:serde_core\"],\"ufmt\":[\"dep:ufmt\",\"dep:ufmt-write\"],\"zeroize\":[\"dep:zeroize\"]}}", "heck_0.5.0": "{\"dependencies\":[],\"features\":{}}", "hermit-abi_0.5.2": "{\"dependencies\":[{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"}],\"features\":{\"default\":[],\"rustc-dep-of-std\":[\"core\",\"alloc\"]}}", "hex_0.4.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"faster-hex\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"rustc-hex\",\"req\":\"^2.1\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"version-sync\",\"req\":\"^0.9\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", @@ -1634,6 +1636,7 @@ "lzma-rust2_0.21.0": "{\"dependencies\":[{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"=0.7\"},{\"features\":[\"static\"],\"kind\":\"dev\",\"name\":\"liblzma\",\"req\":\"^0.4\"},{\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.11\"}],\"features\":{\"default\":[\"std\",\"encoder\",\"optimization\",\"lzip\",\"xz\"],\"encoder\":[],\"lzip\":[],\"optimization\":[],\"std\":[],\"xz\":[\"sha2\"]}}", "mac_address_1.1.8": "{\"dependencies\":[{\"features\":[\"net\"],\"name\":\"nix\",\"req\":\"^0.29\",\"target\":\"cfg(any(target_os = \\\"linux\\\", target_os = \\\"macos\\\", target_os = \\\"freebsd\\\", target_os = \\\"netbsd\\\", target_os = \\\"openbsd\\\", target_os = \\\"android\\\", target_os = \\\"illumos\\\"))\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.198\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.59\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.117\"},{\"features\":[\"winerror\",\"ws2def\",\"iphlpapi\"],\"name\":\"winapi\",\"req\":\"^0.3\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "mach2_0.6.0": "{\"dependencies\":[],\"features\":{\"default\":[],\"unstable\":[]}}", + "managed_0.8.0": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"map\":[],\"std\":[]}}", "maplit_1.0.2": "{\"dependencies\":[],\"features\":{}}", "matchers_0.2.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"syntax\",\"dfa-build\",\"dfa-search\"],\"name\":\"regex-automata\",\"req\":\"^0.4\"}],\"features\":{\"unicode\":[\"regex-automata/unicode\"]}}", "matchit_0.8.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"actix-router\",\"req\":\"^0.2.7\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.4\"},{\"kind\":\"dev\",\"name\":\"gonzales\",\"req\":\"^0.0.3-beta\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"path-tree\",\"req\":\"^0.2.2\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.5.4\"},{\"kind\":\"dev\",\"name\":\"route-recognizer\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"routefinder\",\"req\":\"^0.5.2\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"make\",\"util\"],\"kind\":\"dev\",\"name\":\"tower\",\"req\":\"^0.4\"}],\"features\":{\"__test_helpers\":[],\"default\":[]}}", @@ -2043,6 +2046,7 @@ "smallvec_1.15.1": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"bincode\",\"optional\":true,\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"bincode1\",\"package\":\"bincode\",\"req\":\"^1.0.1\"},{\"kind\":\"dev\",\"name\":\"debugger_test\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"debugger_test_parser\",\"req\":\"^0.1.0\"},{\"default_features\":false,\"name\":\"malloc_size_of\",\"optional\":true,\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"unty\",\"optional\":true,\"req\":\"^0.0.4\"}],\"features\":{\"const_generics\":[],\"const_new\":[\"const_generics\"],\"debugger_visualizer\":[],\"drain_filter\":[],\"drain_keep_rest\":[\"drain_filter\"],\"impl_bincode\":[\"bincode\",\"unty\"],\"may_dangle\":[],\"specialization\":[],\"union\":[],\"write\":[]}}", "smawk_0.3.2": "{\"dependencies\":[{\"name\":\"ndarray\",\"optional\":true,\"req\":\"^0.15.4\"},{\"kind\":\"dev\",\"name\":\"num-traits\",\"req\":\"^0.2.14\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"kind\":\"dev\",\"name\":\"rand_chacha\",\"req\":\"^0.3.1\"},{\"kind\":\"dev\",\"name\":\"version-sync\",\"req\":\"^0.9.4\"}],\"features\":{}}", "smol_str_0.3.5": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.3\"},{\"default_features\":false,\"name\":\"borsh\",\"optional\":true,\"req\":\"^1.4.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.5\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9.2\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"serde\":[\"dep:serde_core\"],\"std\":[\"serde_core?/std\",\"borsh?/std\"]}}", + "smoltcp_0.14.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"byteorder\",\"req\":\"^1.0\"},{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"features\":[\"ip_in_core\"],\"name\":\"defmt\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"getopts\",\"req\":\"^0.2\"},{\"name\":\"heapless\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"idna\",\"req\":\"=1.0.1\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.41.1\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.18\"},{\"default_features\":false,\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.4\"},{\"default_features\":false,\"features\":[\"map\"],\"name\":\"managed\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand_chacha\",\"req\":\"^0.3.1\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.17\"},{\"kind\":\"dev\",\"name\":\"url\",\"req\":\"^2.0\"}],\"features\":{\"_netsim\":[],\"_proto-fragmentation\":[],\"alloc\":[\"managed/alloc\",\"defmt?/alloc\"],\"assembler-max-segment-count-1\":[],\"assembler-max-segment-count-16\":[],\"assembler-max-segment-count-2\":[],\"assembler-max-segment-count-3\":[],\"assembler-max-segment-count-32\":[],\"assembler-max-segment-count-4\":[],\"assembler-max-segment-count-8\":[],\"async\":[],\"auto-icmp-echo-reply\":[],\"default\":[\"std\",\"log\",\"medium-ethernet\",\"medium-ip\",\"medium-ieee802154\",\"phy-raw_socket\",\"phy-tuntap_interface\",\"proto-ipv4\",\"proto-dhcpv4\",\"proto-ipv6\",\"proto-ipv6-slaac\",\"proto-dns\",\"proto-ipv4-fragmentation\",\"proto-sixlowpan-fragmentation\",\"socket-raw\",\"socket-icmp\",\"socket-udp\",\"socket-tcp\",\"socket-dhcpv4\",\"socket-dns\",\"socket-mdns\",\"packetmeta-id\",\"async\",\"multicast\",\"auto-icmp-echo-reply\"],\"defmt\":[\"dep:defmt\",\"heapless/defmt\"],\"dns-max-name-size-128\":[],\"dns-max-name-size-255\":[],\"dns-max-name-size-64\":[],\"dns-max-result-count-1\":[],\"dns-max-result-count-16\":[],\"dns-max-result-count-2\":[],\"dns-max-result-count-3\":[],\"dns-max-result-count-32\":[],\"dns-max-result-count-4\":[],\"dns-max-result-count-8\":[],\"dns-max-server-count-1\":[],\"dns-max-server-count-16\":[],\"dns-max-server-count-2\":[],\"dns-max-server-count-3\":[],\"dns-max-server-count-32\":[],\"dns-max-server-count-4\":[],\"dns-max-server-count-8\":[],\"fragmentation-buffer-size-1024\":[],\"fragmentation-buffer-size-1500\":[],\"fragmentation-buffer-size-16384\":[],\"fragmentation-buffer-size-2048\":[],\"fragmentation-buffer-size-256\":[],\"fragmentation-buffer-size-32768\":[],\"fragmentation-buffer-size-4096\":[],\"fragmentation-buffer-size-512\":[],\"fragmentation-buffer-size-65536\":[],\"fragmentation-buffer-size-8192\":[],\"iface-max-addr-count-1\":[],\"iface-max-addr-count-2\":[],\"iface-max-addr-count-3\":[],\"iface-max-addr-count-4\":[],\"iface-max-addr-count-5\":[],\"iface-max-addr-count-6\":[],\"iface-max-addr-count-7\":[],\"iface-max-addr-count-8\":[],\"iface-max-multicast-group-count-1\":[],\"iface-max-multicast-group-count-1024\":[],\"iface-max-multicast-group-count-128\":[],\"iface-max-multicast-group-count-16\":[],\"iface-max-multicast-group-count-2\":[],\"iface-max-multicast-group-count-256\":[],\"iface-max-multicast-group-count-3\":[],\"iface-max-multicast-group-count-32\":[],\"iface-max-multicast-group-count-4\":[],\"iface-max-multicast-group-count-5\":[],\"iface-max-multicast-group-count-512\":[],\"iface-max-multicast-group-count-6\":[],\"iface-max-multicast-group-count-64\":[],\"iface-max-multicast-group-count-7\":[],\"iface-max-multicast-group-count-8\":[],\"iface-max-prefix-count-1\":[],\"iface-max-prefix-count-2\":[],\"iface-max-prefix-count-3\":[],\"iface-max-prefix-count-4\":[],\"iface-max-prefix-count-5\":[],\"iface-max-prefix-count-6\":[],\"iface-max-prefix-count-7\":[],\"iface-max-prefix-count-8\":[],\"iface-max-route-count-0\":[],\"iface-max-route-count-1\":[],\"iface-max-route-count-1024\":[],\"iface-max-route-count-128\":[],\"iface-max-route-count-16\":[],\"iface-max-route-count-2\":[],\"iface-max-route-count-256\":[],\"iface-max-route-count-3\":[],\"iface-max-route-count-32\":[],\"iface-max-route-count-4\":[],\"iface-max-route-count-5\":[],\"iface-max-route-count-512\":[],\"iface-max-route-count-6\":[],\"iface-max-route-count-64\":[],\"iface-max-route-count-7\":[],\"iface-max-route-count-8\":[],\"iface-max-sixlowpan-address-context-count-1\":[],\"iface-max-sixlowpan-address-context-count-1024\":[],\"iface-max-sixlowpan-address-context-count-128\":[],\"iface-max-sixlowpan-address-context-count-16\":[],\"iface-max-sixlowpan-address-context-count-2\":[],\"iface-max-sixlowpan-address-context-count-256\":[],\"iface-max-sixlowpan-address-context-count-3\":[],\"iface-max-sixlowpan-address-context-count-32\":[],\"iface-max-sixlowpan-address-context-count-4\":[],\"iface-max-sixlowpan-address-context-count-5\":[],\"iface-max-sixlowpan-address-context-count-512\":[],\"iface-max-sixlowpan-address-context-count-6\":[],\"iface-max-sixlowpan-address-context-count-64\":[],\"iface-max-sixlowpan-address-context-count-7\":[],\"iface-max-sixlowpan-address-context-count-8\":[],\"iface-neighbor-cache-count-1\":[],\"iface-neighbor-cache-count-1024\":[],\"iface-neighbor-cache-count-128\":[],\"iface-neighbor-cache-count-16\":[],\"iface-neighbor-cache-count-2\":[],\"iface-neighbor-cache-count-256\":[],\"iface-neighbor-cache-count-3\":[],\"iface-neighbor-cache-count-32\":[],\"iface-neighbor-cache-count-4\":[],\"iface-neighbor-cache-count-5\":[],\"iface-neighbor-cache-count-512\":[],\"iface-neighbor-cache-count-6\":[],\"iface-neighbor-cache-count-64\":[],\"iface-neighbor-cache-count-7\":[],\"iface-neighbor-cache-count-8\":[],\"ipv6-hbh-max-options-1\":[],\"ipv6-hbh-max-options-16\":[],\"ipv6-hbh-max-options-2\":[],\"ipv6-hbh-max-options-3\":[],\"ipv6-hbh-max-options-32\":[],\"ipv6-hbh-max-options-4\":[],\"ipv6-hbh-max-options-8\":[],\"medium-ethernet\":[\"socket\"],\"medium-ieee802154\":[\"socket\",\"proto-sixlowpan\"],\"medium-ip\":[\"socket\"],\"multicast\":[],\"packetmeta-id\":[],\"phy-raw_socket\":[\"std\",\"libc\"],\"phy-tuntap_interface\":[\"std\",\"libc\"],\"proto-dhcpv4\":[\"proto-ipv4\"],\"proto-dns\":[],\"proto-ipsec\":[\"proto-ipsec-ah\",\"proto-ipsec-esp\"],\"proto-ipsec-ah\":[],\"proto-ipsec-esp\":[],\"proto-ipv4\":[],\"proto-ipv4-fragmentation\":[\"proto-ipv4\",\"_proto-fragmentation\"],\"proto-ipv6\":[],\"proto-ipv6-fragmentation\":[\"proto-ipv6\",\"_proto-fragmentation\"],\"proto-ipv6-hbh\":[\"proto-ipv6\"],\"proto-ipv6-routing\":[\"proto-ipv6\"],\"proto-ipv6-slaac\":[\"proto-ipv6\"],\"proto-rpl\":[\"proto-ipv6-hbh\",\"proto-ipv6-routing\"],\"proto-sixlowpan\":[\"proto-ipv6\"],\"proto-sixlowpan-fragmentation\":[\"proto-sixlowpan\",\"_proto-fragmentation\"],\"reassembly-buffer-count-1\":[],\"reassembly-buffer-count-16\":[],\"reassembly-buffer-count-2\":[],\"reassembly-buffer-count-3\":[],\"reassembly-buffer-count-32\":[],\"reassembly-buffer-count-4\":[],\"reassembly-buffer-count-8\":[],\"reassembly-buffer-size-1024\":[],\"reassembly-buffer-size-1500\":[],\"reassembly-buffer-size-16384\":[],\"reassembly-buffer-size-2048\":[],\"reassembly-buffer-size-256\":[],\"reassembly-buffer-size-32768\":[],\"reassembly-buffer-size-4096\":[],\"reassembly-buffer-size-512\":[],\"reassembly-buffer-size-65536\":[],\"reassembly-buffer-size-8192\":[],\"rpl-parents-buffer-count-16\":[],\"rpl-parents-buffer-count-2\":[],\"rpl-parents-buffer-count-32\":[],\"rpl-parents-buffer-count-4\":[],\"rpl-parents-buffer-count-8\":[],\"rpl-relations-buffer-count-1\":[],\"rpl-relations-buffer-count-128\":[],\"rpl-relations-buffer-count-16\":[],\"rpl-relations-buffer-count-2\":[],\"rpl-relations-buffer-count-32\":[],\"rpl-relations-buffer-count-4\":[],\"rpl-relations-buffer-count-64\":[],\"rpl-relations-buffer-count-8\":[],\"segmentation-offload\":[],\"socket\":[],\"socket-dhcpv4\":[\"socket\",\"medium-ethernet\",\"proto-dhcpv4\"],\"socket-dns\":[\"socket\",\"proto-dns\"],\"socket-icmp\":[\"socket\"],\"socket-mdns\":[\"socket-dns\"],\"socket-raw\":[\"socket\"],\"socket-tcp\":[\"socket\"],\"socket-tcp-cubic\":[],\"socket-tcp-pause-synack\":[\"socket-tcp\"],\"socket-tcp-reno\":[],\"socket-udp\":[\"socket\"],\"std\":[\"managed/std\",\"alloc\"],\"verbose\":[]}}", "socket2_0.5.10": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.171\",\"target\":\"cfg(unix)\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\",\"Win32_System_IO\",\"Win32_System_Threading\",\"Win32_System_WindowsProgramming\"],\"name\":\"windows-sys\",\"req\":\"^0.52\",\"target\":\"cfg(windows)\"}],\"features\":{\"all\":[]}}", "socket2_0.6.3": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.172\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\"))\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\",\"Win32_System_IO\",\"Win32_System_Threading\",\"Win32_System_WindowsProgramming\"],\"name\":\"windows-sys\",\"req\":\">=0.60, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"all\":[]}}", "socks5-protocol_0.3.5": "{\"dependencies\":[{\"name\":\"thiserror\",\"req\":\"^1.0\"},{\"features\":[\"io-util\"],\"name\":\"tokio\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"sync\"],\"doc\":[\"tokio/rt-multi-thread\",\"tokio/macros\"],\"sync\":[]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 38610c69b4..7ff1484ad7 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4055,6 +4055,7 @@ dependencies = [ name = "codex-linux-sandbox" version = "0.0.0" dependencies = [ + "async-lock", "clap", "codex-core", "codex-install-context", @@ -4065,17 +4066,20 @@ dependencies = [ "codex-uds", "codex-utils-absolute-path", "codex-utils-cargo-bin", + "etherparse", "hickory-proto 0.26.3", "ignore", "indexmap 2.14.0", "landlock", "libc", "pretty_assertions", + "rand 0.9.3", "rustix 1.1.4", "seccompiler", "serde", "serde_json", "sha2 0.10.9", + "smoltcp", "socks5-protocol", "tempfile", "test-case", @@ -7455,6 +7459,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "etherparse" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17304d06addb3283cdc4bd528e42dd95e73c8ee2d6492ffce415e93660885449" +dependencies = [ + "arrayvec", +] + [[package]] name = "euclid" version = "0.22.14" @@ -9120,6 +9133,16 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -10560,6 +10583,12 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" +[[package]] +name = "managed" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d" + [[package]] name = "maplit" version = "1.0.2" @@ -14797,6 +14826,21 @@ dependencies = [ "serde_core", ] +[[package]] +name = "smoltcp" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f8b28ad56c6e35524a37dd492af5d1a47e31e1a4d175cd12f89c075f01980f" +dependencies = [ + "bitflags 1.3.2", + "byteorder", + "cfg-if", + "defmt 1.1.1", + "heapless 0.9.3", + "libc", + "managed", +] + [[package]] name = "socket2" version = "0.5.10" diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 4f074f28d9..d47cd5ac12 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -101,6 +101,7 @@ pub async fn run_command_under_landlock( loader_overrides: LoaderOverrides, ) -> anyhow::Result<()> { let LandlockCommand { + transparent_tcp, sandbox_state, permissions_profile, config_profile: _, @@ -124,7 +125,7 @@ pub async fn run_command_under_landlock( command, config_overrides, codex_linux_sandbox_exe, - SandboxType::Landlock, + SandboxType::Landlock { transparent_tcp }, /*log_denials*/ false, &[], ) @@ -170,7 +171,9 @@ pub async fn run_command_under_windows_sandbox( enum SandboxType { #[cfg(target_os = "macos")] Seatbelt, - Landlock, + Landlock { + transparent_tcp: bool, + }, Windows, } @@ -304,12 +307,21 @@ async fn run_command_under_sandbox( config.effective_local_windows_sandbox_type() }; + let transparent_tcp = matches!( + sandbox_type, + SandboxType::Landlock { + transparent_tcp: true + } + ); let sandbox = match permission_profile.enforcement() { + SandboxEnforcement::Disabled | SandboxEnforcement::External if transparent_tcp => { + anyhow::bail!("--transparent-tcp requires a managed Linux sandbox"); + } SandboxEnforcement::Disabled | SandboxEnforcement::External => SandboxBackend::None, SandboxEnforcement::Managed => match sandbox_type { #[cfg(target_os = "macos")] SandboxType::Seatbelt => SandboxBackend::MacosSeatbelt, - SandboxType::Landlock => SandboxBackend::LinuxSeccomp, + SandboxType::Landlock { .. } => SandboxBackend::LinuxSeccomp, #[cfg(windows)] SandboxType::Windows if windows_sandbox_type == WindowsMxc => WindowsMxc, #[cfg(windows)] @@ -386,6 +398,10 @@ async fn run_command_under_sandbox( let manager = SandboxManager::for_inherited_terminal(allow_unix_sockets); #[cfg(not(any(target_os = "linux", target_os = "macos")))] let manager = SandboxManager::new(); + if transparent_tcp && network.is_none() { + anyhow::bail!("--transparent-tcp requires a managed network proxy"); + } + let manager = manager.with_linux_sandbox_transparent_tcp(transparent_tcp); let workspace_roots = sandbox_state.is_none().then(|| { config .permissions diff --git a/codex-rs/cli/src/lib.rs b/codex-rs/cli/src/lib.rs index dbfd0bc0e7..48422dcb14 100644 --- a/codex-rs/cli/src/lib.rs +++ b/codex-rs/cli/src/lib.rs @@ -109,6 +109,10 @@ fn parse_absolute_path(raw: &str) -> Result { #[derive(Debug, Parser)] pub struct LandlockCommand { + /// Route outbound TCP through the managed SOCKS proxy. + #[arg(long)] + pub transparent_tcp: bool, + #[command(flatten)] pub sandbox_state: SandboxStateArgs, diff --git a/codex-rs/cli/src/snapshots/codex__exec_server_args_tests__exec_server_help_documents_remote_options.snap b/codex-rs/cli/src/snapshots/codex__exec_server_args_tests__exec_server_help_documents_remote_options.snap index 3cddcfa1c5..3d634462ad 100644 --- a/codex-rs/cli/src/snapshots/codex__exec_server_args_tests__exec_server_help_documents_remote_options.snap +++ b/codex-rs/cli/src/snapshots/codex__exec_server_args_tests__exec_server_help_documents_remote_options.snap @@ -39,6 +39,13 @@ Options: [default: isolate] + --linux-sandbox-transparent-tcp + Route managed Linux sandbox TCP through the SOCKS proxy + + --tcp-upstream-proxy + Require this HTTP CONNECT proxy for raw TCP from the managed SOCKS + listener + --proxy-private-ips-via-upstream Allow permitted private IP destinations to use the configured upstream proxy. If no valid upstream proxy applies to the request protocol, diff --git a/codex-rs/deny.toml b/codex-rs/deny.toml index 7e89bd7aab..b9269d44e4 100644 --- a/codex-rs/deny.toml +++ b/codex-rs/deny.toml @@ -187,7 +187,9 @@ confidence-threshold = 0.8 exceptions = [ # Each entry is the crate and version constraint, and its specific allow # list - #{ allow = ["Zlib"], crate = "adler32" }, + # The bundled Linux TCP stack and its storage library are both 0BSD. + { allow = ["0BSD"], crate = "managed@0.8.0" }, + { allow = ["0BSD"], crate = "smoltcp@0.14.0" }, ] # Some crates don't have (easily) machine readable licensing information, diff --git a/codex-rs/exec-server-cli/src/command.rs b/codex-rs/exec-server-cli/src/command.rs index 0615a2c9e9..fd630310c0 100644 --- a/codex-rs/exec-server-cli/src/command.rs +++ b/codex-rs/exec-server-cli/src/command.rs @@ -41,6 +41,14 @@ pub struct ExecServerCommand { #[arg(long, value_name = "MODE", default_value = "isolate", global = true)] linux_sandbox_pid_namespace: codex_sandboxing::LinuxSandboxPidNamespace, + /// Route managed Linux sandbox TCP through the SOCKS proxy. + #[arg(long, global = true)] + linux_sandbox_transparent_tcp: bool, + + /// Require this HTTP CONNECT proxy for raw TCP from the managed SOCKS listener. + #[arg(long, value_name = "HOST:PORT", global = true)] + tcp_upstream_proxy: Option, + /// Allow permitted private IP destinations to use the configured upstream proxy. /// If no valid upstream proxy applies to the request protocol, connect directly. /// Loopback stays local. This flag does not require upstream routing. @@ -203,7 +211,9 @@ impl ExecServerCommand { arg0_paths.codex_linux_sandbox_exe.clone(), )? .with_linux_sandbox_pid_namespace(self.linux_sandbox_pid_namespace) + .with_linux_sandbox_transparent_tcp(self.linux_sandbox_transparent_tcp) .with_proxy_private_ips_via_upstream(self.proxy_private_ips_via_upstream); + runtime_paths.tcp_upstream_proxy = self.tcp_upstream_proxy.take(); // Config loading below owns error handling (non-strict stdio can proceed // without config). Never retain other startup flags in the executor. if let Ok(overrides) = root_config_overrides.parse_overrides() { diff --git a/codex-rs/exec-server-cli/src/snapshots/codex_exec_server_cli__command__tests__standalone_help_documents_executor_options.snap b/codex-rs/exec-server-cli/src/snapshots/codex_exec_server_cli__command__tests__standalone_help_documents_executor_options.snap index 5246746c60..e292b9fa35 100644 --- a/codex-rs/exec-server-cli/src/snapshots/codex_exec_server_cli__command__tests__standalone_help_documents_executor_options.snap +++ b/codex-rs/exec-server-cli/src/snapshots/codex_exec_server_cli__command__tests__standalone_help_documents_executor_options.snap @@ -1,6 +1,6 @@ --- source: exec-server-cli/src/exec_server_args_tests.rs -assertion_line: 318 +assertion_line: 338 expression: help --- [EXPERIMENTAL] Run the standalone exec-server service @@ -33,6 +33,13 @@ Options: [default: isolate] + --linux-sandbox-transparent-tcp + Route managed Linux sandbox TCP through the SOCKS proxy + + --tcp-upstream-proxy + Require this HTTP CONNECT proxy for raw TCP from the managed SOCKS + listener + --proxy-private-ips-via-upstream Allow permitted private IP destinations to use the configured upstream proxy. If no valid upstream proxy applies to the request protocol, diff --git a/codex-rs/exec-server/README.md b/codex-rs/exec-server/README.md index 7d95b845cf..856b94dc27 100644 --- a/codex-rs/exec-server/README.md +++ b/codex-rs/exec-server/README.md @@ -537,6 +537,19 @@ Destination policy still applies to every route. Enforcing mandatory upstream ro would require a separate fail-closed mode. A connection failure after selecting an upstream proxy is returned as an error, without retrying directly. +### Transparent TCP + +```sh +codex exec-server --linux-sandbox-transparent-tcp --tcp-upstream-proxy proxy:8088 +``` + +The executor chooses the mandatory plaintext HTTP CONNECT next hop for opaque SOCKS traffic. +HTTP clients retain their existing proxy configuration. A failed CONNECT never retries directly. +The upstream resolves hostnames; named tunnels are refused if local binding is disabled or +an IP deny/review rule requires local resolution. Private IP literals additionally require +`--proxy-private-ips-via-upstream`; loopback, link-local and the upstream itself are rejected. +The upstream must enforce policy on the address it dials. See the [Linux host requirements](../linux-sandbox/README.md#transparent-tcp). + ## Example session Initialize: diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index 4ef470c149..98fa92503a 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -110,6 +110,7 @@ pub use codex_file_system::WalkOptions; pub use codex_file_system::WalkOutcome; pub use codex_file_system::WindowsSandboxSelection; pub use codex_file_system::WriteFileOptions; +pub use codex_network_proxy::TcpUpstreamProxy; pub use codex_protocol::shell_environment::CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR; pub use environment::CODEX_EXEC_SERVER_NOISE_CHATGPT_ACCOUNT_ID_ENV_VAR; pub use environment::CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID_ENV_VAR; diff --git a/codex-rs/exec-server/src/process_sandbox.rs b/codex-rs/exec-server/src/process_sandbox.rs index 088f537e9e..07d7b4ec0b 100644 --- a/codex-rs/exec-server/src/process_sandbox.rs +++ b/codex-rs/exec-server/src/process_sandbox.rs @@ -135,7 +135,7 @@ pub(crate) async fn prepare_exec_request_with_telemetry( prepare_managed_network( params, credential_brokers, - runtime_paths.is_some_and(|paths| paths.proxy_private_ips_via_upstream), + runtime_paths, env, network_policy_decider, network_policy_audit_observer, @@ -182,7 +182,8 @@ pub(crate) async fn prepare_exec_request_with_telemetry( }); runtime_readable_paths.extend(managed_mitm_ca_trust_bundle_path); let sandbox_manager = SandboxManager::new() - .with_linux_sandbox_pid_namespace(runtime_paths.linux_sandbox_pid_namespace); + .with_linux_sandbox_pid_namespace(runtime_paths.linux_sandbox_pid_namespace) + .with_linux_sandbox_transparent_tcp(runtime_paths.linux_sandbox_transparent_tcp); #[cfg(target_os = "macos")] let sandbox_manager = sandbox_manager .with_allowed_symlinked_codex_home(runtime_paths.allowed_symlinked_codex_home.clone()); @@ -304,7 +305,7 @@ pub(crate) async fn prepare_exec_request_with_telemetry( async fn prepare_managed_network( params: &ExecParams, credential_brokers: &RemoteCredentialBrokerCache, - proxy_private_ips_via_upstream: bool, + runtime_paths: Option<&ExecServerRuntimeOptions>, env: HashMap, network_policy_decider: Option>, network_policy_audit_observer: Option, @@ -318,8 +319,18 @@ async fn prepare_managed_network( ), JSONRPCErrorError, > { + let without_executor_proxy = |env| { + if (params.enforce_managed_network || params.managed_network.is_some()) + && runtime_paths.is_some_and(|paths| paths.tcp_upstream_proxy.is_some()) + { + return Err(invalid_params( + "a mandatory TCP upstream requires an executor-local managed proxy".to_string(), + )); + } + Ok((env, params.managed_network.clone(), None, None)) + }; let Some(launch) = ¶ms.network_proxy else { - return Ok((env, params.managed_network.clone(), None, None)); + return without_executor_proxy(env); }; #[cfg(windows)] let routing = match params @@ -331,7 +342,7 @@ async fn prepare_managed_network( // Native Windows commands have no sandbox token SID for shared ingress. let mut native_env = env; codex_network_proxy::strip_managed_proxy_env(&mut native_env); - return Ok((native_env, params.managed_network.clone(), None, None)); + return without_executor_proxy(native_env); } Some(WindowsSandboxSelection::Mxc) => ManagedProxyRouting::DedicatedListeners, Some( @@ -358,7 +369,12 @@ async fn prepare_managed_network( )) })? .credential_broker; - state.set_proxy_private_ips_via_upstream(proxy_private_ips_via_upstream); + if let Some(paths) = runtime_paths { + state.set_proxy_private_ips_via_upstream(paths.proxy_private_ips_via_upstream); + if let Some(proxy) = &paths.tcp_upstream_proxy { + state.set_tcp_upstream_proxy(proxy.clone()); + } + } if let Some(observer) = network_policy_audit_observer { state.set_policy_audit_observer(observer); } diff --git a/codex-rs/exec-server/src/process_sandbox_tests.rs b/codex-rs/exec-server/src/process_sandbox_tests.rs index d0cd73545e..c3256c5560 100644 --- a/codex-rs/exec-server/src/process_sandbox_tests.rs +++ b/codex-rs/exec-server/src/process_sandbox_tests.rs @@ -500,6 +500,26 @@ async fn native_request_preserves_native_launch_fields() { assert_eq!(prepared.cwd, cwd); assert_eq!(prepared.env, env); assert_eq!(prepared.arg0, params.arg0); + + let mut legacy = params; + legacy.enforce_managed_network = true; + let mut runtime = ExecServerRuntimeOptions::new( + std::env::current_exe().unwrap(), + /*codex_linux_sandbox_exe*/ None, + ) + .unwrap(); + runtime.tcp_upstream_proxy = Some("127.0.0.1:8088".parse().unwrap()); + let error = prepare_exec_request( + &legacy, + env, + Some(&runtime), + /*network_policy_decider*/ None, + /*network_policy_audit_observer*/ None, + ) + .await + .err() + .expect("legacy enforcement must require the mandatory upstream"); + assert!(error.message.contains("executor-local managed proxy")); } #[test_case(false, None, &["-c", "true"]; "unmasked")] diff --git a/codex-rs/exec-server/src/runtime_options.rs b/codex-rs/exec-server/src/runtime_options.rs index e3146c6e57..1eba1c70ca 100644 --- a/codex-rs/exec-server/src/runtime_options.rs +++ b/codex-rs/exec-server/src/runtime_options.rs @@ -15,8 +15,12 @@ pub struct ExecServerRuntimeOptions { pub codex_linux_sandbox_exe: Option, /// Trusted startup policy; requests and repository config cannot change PID isolation. pub linux_sandbox_pid_namespace: LinuxSandboxPidNamespace, + /// Enable transparent TCP for managed Linux command launches. + pub linux_sandbox_transparent_tcp: bool, /// Trusted startup routing; request policy still controls destination access. pub proxy_private_ips_via_upstream: bool, + /// Mandatory next hop for opaque SOCKS TCP when set by provisioning. + pub tcp_upstream_proxy: Option, /// Only the sandbox preference from CLI flags, never other startup config or secrets. pub prefer_mxc: Option, /// User-config opt-out of writable-root symlink checks beneath this host's home. @@ -44,7 +48,9 @@ impl ExecServerRuntimeOptions { ) -> std::io::Result { Ok(Self { linux_sandbox_pid_namespace: LinuxSandboxPidNamespace::default(), + linux_sandbox_transparent_tcp: false, proxy_private_ips_via_upstream: false, + tcp_upstream_proxy: None, prefer_mxc: None, codex_self_exe: absolute_path(codex_self_exe)?, codex_linux_sandbox_exe: codex_linux_sandbox_exe.map(absolute_path).transpose()?, @@ -59,6 +65,11 @@ impl ExecServerRuntimeOptions { self } + pub fn with_linux_sandbox_transparent_tcp(mut self, enabled: bool) -> Self { + self.linux_sandbox_transparent_tcp = enabled; + self + } + /// Routes permitted private IPs through the executor's configured upstream proxy. pub fn with_proxy_private_ips_via_upstream(mut self, enabled: bool) -> Self { self.proxy_private_ips_via_upstream = enabled; diff --git a/codex-rs/linux-sandbox/BUILD.bazel b/codex-rs/linux-sandbox/BUILD.bazel index 984f870ba2..af3e399c2b 100644 --- a/codex-rs/linux-sandbox/BUILD.bazel +++ b/codex-rs/linux-sandbox/BUILD.bazel @@ -10,4 +10,22 @@ codex_rust_crate( "@platforms//os:linux": ["//codex-rs/bwrap:bwrap-sha256-env"], "//conditions:default": [], }), + test_data_extra = [":managed-tcp-test-data"], +) + +config_setting( + name = "linux-x86_64", + constraint_values = [ + "@platforms//cpu:x86_64", + "@platforms//os:linux", + ], +) + +filegroup( + name = "managed-tcp-test-data", + testonly = True, + srcs = select({ + ":linux-x86_64": ["@managed_tcp_test_bubblewrap_noble_amd64//:usr/bin/bwrap"], + "//conditions:default": [], + }), ) diff --git a/codex-rs/linux-sandbox/Cargo.toml b/codex-rs/linux-sandbox/Cargo.toml index b3b20cc7c1..a0ffd0fff1 100644 --- a/codex-rs/linux-sandbox/Cargo.toml +++ b/codex-rs/linux-sandbox/Cargo.toml @@ -17,6 +17,7 @@ doctest = false workspace = true [target.'cfg(target_os = "linux")'.dependencies] +async-lock = "3" clap = { workspace = true, features = ["derive"] } codex-install-context = { workspace = true } codex-network-proxy = { workspace = true } @@ -25,24 +26,26 @@ codex-protocol = { workspace = true } codex-sandboxing = { workspace = true } codex-uds = { workspace = true } codex-utils-absolute-path = { workspace = true } +etherparse = { version = "=0.21.0", default-features = false } +hickory-proto = "=0.26.3" ignore = { workspace = true } indexmap = { workspace = true } landlock = { workspace = true } +rand = { workspace = true } libc = { workspace = true } -rustix = { workspace = true } +rustix = { workspace = true, features = ["process", "thread"] } seccompiler = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } sha2 = { workspace = true } -tokio = { workspace = true, features = ["sync"] } +smoltcp = { version = "=0.14.0", default-features = false, features = ["std", "phy-tuntap_interface", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp"] } +socks5-protocol = { version = "=0.3.5", features = ["sync"] } url = { workspace = true } [target.'cfg(target_os = "linux")'.dev-dependencies] codex-core = { workspace = true } codex-utils-cargo-bin = { workspace = true } -hickory-proto = "=0.26.3" pretty_assertions = { workspace = true } -socks5-protocol = { version = "=0.3.5", features = ["sync"] } tempfile = { workspace = true } test-case = "3.3.1" tokio = { workspace = true, features = [ @@ -51,4 +54,5 @@ tokio = { workspace = true, features = [ "process", "rt-multi-thread", "signal", + "sync", ] } diff --git a/codex-rs/linux-sandbox/README.md b/codex-rs/linux-sandbox/README.md index 164e316e2c..d750de23b7 100644 --- a/codex-rs/linux-sandbox/README.md +++ b/codex-rs/linux-sandbox/README.md @@ -13,7 +13,29 @@ seccomp filters; it also retains the legacy Landlock filesystem implementation. `src/linux_run_main.rs` coordinates these stages through `LinuxSandboxCommand`. End-to-end coverage lives in `tests/suite/sandbox.rs` and the other suite modules. -On Linux, Codex prefers the first `bwrap` found on `PATH` +### Transparent TCP + +```sh +codex sandbox --transparent-tcp --permission-profile db -- psql -h pg.example +``` + +The Rust sandbox helper includes the TCP stack. Codex starts it automatically. +The Linux host needs distro `bwrap` on `PATH`, user and network namespaces, IPv6, +and read/write access to `/dev/net/tun`. +Use full managed SOCKS5 mode for native database ports and an isolated PID namespace. +For executor-wide capture and HTTP CONNECT routing, see the [exec-server setup](../exec-server/README.md#transparent-tcp). + +The private DNS stub maps up to 8,192 names to stable addresses in `198.18.0.0/15` +and `fdc0:de00::/96`. The relay sends the original name and port to the attributed proxy; +literal addresses, including entries from a hosts file, are checked as IPs. Unknown +synthetic addresses are rejected. Loopback stays local. Each bridge permits 128 concurrent +connections. When the TCP stack is full, clients retry their opening SYN. The relay identifies each +native socket by both endpoints and its inode in the private namespace TCP tables. Closing that +socket cancels its pending approval connection, including when other clients use the same +destination. A client that shuts down only its write side can still receive a reply. General UDP, +QUIC, ICMP, legacy Landlock, host PID inheritance, and procfs fallback are outside this mode. + +With transparent TCP off, Codex prefers the first `bwrap` found on `PATH` outside the current working directory whenever it is available. If `bwrap` is present but too old to support `--argv0`, the helper keeps using system bubblewrap and switches to a @@ -100,7 +122,8 @@ commands that would enter the bubblewrap path. sockets unless `dangerously_allow_all_unix_sockets` is granted. Unix socket pairs remain allowed for communication between related processes. - When bubblewrap is active, it mounts a fresh `/proc` via `--proc /proc` by default. - If that mount is denied, it retains the inherited `/proc` and still creates a + Transparent TCP requires this mount and aborts the launch if it fails. + In other modes, if that mount is denied, it retains the inherited `/proc` and still creates a PID namespace, preserving the existing fallback. `--no-proc` also retains the inherited `/proc` without disabling PID isolation. In these cases, process IDs inside the sandbox can differ from those exposed by `/proc`. Default invocations diff --git a/codex-rs/linux-sandbox/managed_tcp_test_bwrap.bzl b/codex-rs/linux-sandbox/managed_tcp_test_bwrap.bzl new file mode 100644 index 0000000000..9ca3f6d64a --- /dev/null +++ b/codex-rs/linux-sandbox/managed_tcp_test_bwrap.bzl @@ -0,0 +1,13 @@ +"""Expose the Ubuntu 24.04 bubblewrap executable as a Bazel test fixture.""" + +def _extract(ctx): + ctx.download_and_extract( + url = "https://archive.ubuntu.com/ubuntu/pool/main/b/bubblewrap/bubblewrap_0.9.0-1ubuntu0.3_amd64.deb", + sha256 = "2461f1beee9cb04c8942739fe1a2b37e7b7c2a3d518f0779dc75f9245baa3094", + type = "deb", + ) + ctx.extract("data.tar.zst") + ctx.file("BUILD.bazel", 'exports_files(["usr/bin/bwrap"])') + return ctx.repo_metadata(reproducible = True) + +managed_tcp_test_bwrap = repository_rule(implementation = _extract) diff --git a/codex-rs/linux-sandbox/src/bwrap.rs b/codex-rs/linux-sandbox/src/bwrap.rs index 64262c9400..f822a6daec 100644 --- a/codex-rs/linux-sandbox/src/bwrap.rs +++ b/codex-rs/linux-sandbox/src/bwrap.rs @@ -45,7 +45,7 @@ use ignore::overrides::OverrideBuilder; /// /// These are intentionally system-level paths only (plus Nix store roots) so /// `include_platform_defaults` does not silently widen access to user data. -const LINUX_PLATFORM_DEFAULT_READ_ROOTS: &[&str] = &[ +pub(super) const LINUX_PLATFORM_DEFAULT_READ_ROOTS: &[&str] = &[ "/bin", "/sbin", "/usr", @@ -107,11 +107,13 @@ pub(crate) enum BwrapNetworkMode { /// Bubblewrap enforces this by unsharing the network namespace. The /// proxy-routing bridge is established by the helper process after startup. ProxyOnly, + /// The transparent TCP setup already isolated this command's network namespace. + PreparedProxy, } impl BwrapNetworkMode { fn should_unshare_network(self) -> bool { - !matches!(self, Self::FullAccess) + matches!(self, Self::Isolated | Self::ProxyOnly) } } diff --git a/codex-rs/linux-sandbox/src/launcher.rs b/codex-rs/linux-sandbox/src/launcher.rs index b4455ff210..7398bc80f2 100644 --- a/codex-rs/linux-sandbox/src/launcher.rs +++ b/codex-rs/linux-sandbox/src/launcher.rs @@ -135,6 +135,7 @@ fn translate_legacy_bwrap_fd_mounts(argv: &mut Vec) -> Result<(), String pub(crate) fn initialize_bwrap_launcher( file_system_policy: &FileSystemSandboxPolicy, sandbox_policy_cwd: &Path, + require_system: bool, ) { LAUNCHER.get_or_init(|| { if let Some(path) = find_system_bwrap_in_path(file_system_policy, sandbox_policy_cwd) @@ -143,6 +144,9 @@ pub(crate) fn initialize_bwrap_launcher( return BubblewrapLauncher::System(launcher); } + if require_system { + panic!("transparent TCP requires a supported distro bubblewrap on PATH"); + } match bundled_bwrap::launcher() { Some(launcher) => BubblewrapLauncher::Bundled(launcher), None => BubblewrapLauncher::Unavailable, diff --git a/codex-rs/linux-sandbox/src/lib.rs b/codex-rs/linux-sandbox/src/lib.rs index b1ad51a378..a9109c46db 100644 --- a/codex-rs/linux-sandbox/src/lib.rs +++ b/codex-rs/linux-sandbox/src/lib.rs @@ -19,9 +19,9 @@ mod fd_mount; mod launcher; #[cfg(target_os = "linux")] mod linux_run_main; -#[cfg(all(target_os = "linux", test))] +#[cfg(target_os = "linux")] mod managed_tcp; -#[cfg(all(target_os = "linux", test))] +#[cfg(target_os = "linux")] mod managed_tcp_dns; #[cfg(target_os = "linux")] mod proxy_lifecycle; @@ -30,6 +30,10 @@ mod proxy_routing; #[cfg(target_os = "linux")] mod seccomp; #[cfg(target_os = "linux")] +mod tcp_namespace; +#[cfg(target_os = "linux")] +mod tcp_net_setup; +#[cfg(target_os = "linux")] mod wslg; #[cfg(target_os = "linux")] diff --git a/codex-rs/linux-sandbox/src/linux_run_main.rs b/codex-rs/linux-sandbox/src/linux_run_main.rs index feae1d36d0..1849aeedd2 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main.rs @@ -22,8 +22,10 @@ use crate::bwrap::create_bwrap_command_args; use crate::launcher::exec_bwrap; use crate::launcher::initialize_bwrap_launcher; use crate::launcher::preferred_bwrap_supports_argv0; +use crate::managed_tcp::Relay; use crate::proxy_routing::activate_proxy_routes_in_netns; use crate::proxy_routing::prepare_host_proxy_route_spec; +use crate::seccomp::ProxyRouting; use crate::seccomp::apply_sandbox_policies_to_current_thread; use codex_network_proxy::ManagedNetworkSandboxContext; use codex_protocol::error::Result as CodexResult; @@ -41,7 +43,6 @@ static PENDING_FORWARDED_SIGNAL: AtomicI32 = AtomicI32::new(0); const FORWARDED_SIGNALS: &[libc::c_int] = &[libc::SIGHUP, libc::SIGINT, libc::SIGQUIT, libc::SIGTERM]; -const LINUX_CAPABILITY_VERSION_3: u32 = 0x2008_0522; const SYNTHETIC_MOUNT_MARKER_SYNTHETIC: &[u8] = b"synthetic\n"; const SYNTHETIC_MOUNT_MARKER_EXISTING: &[u8] = b"existing\n"; @@ -105,6 +106,14 @@ pub struct LinuxSandboxCommand { #[arg(long = "proxy-route-spec", hide = true)] pub proxy_route_spec: Option, + /// Transparently route outbound TCP through the managed SOCKS proxy. + #[arg(long)] + pub transparent_tcp: bool, + + /// Private TUN and resolver handoff from the trusted outer helper. + #[arg(long, hide = true, value_delimiter = ',')] + transparent_tcp_fd: Vec, + /// Inherited fallback mounts that must be authenticated before sandboxed code runs. #[arg(long = "verify-fd-mount", hide = true)] pub verify_fd_mounts: Vec, @@ -141,12 +150,27 @@ pub fn run_main() -> ! { apply_seccomp_then_exec, managed_network, proxy_route_spec, + transparent_tcp, + transparent_tcp_fd, verify_fd_mounts, no_proc, inherit_pid_namespace, command, } = LinuxSandboxCommand::parse(); let allow_network_for_proxy = managed_network.is_some(); + let routing = if transparent_tcp { + ProxyRouting::TransparentTcp + } else { + ProxyRouting::Explicit + }; + if transparent_tcp + && (!allow_network_for_proxy || use_legacy_landlock || inherit_pid_namespace || no_proc) + { + panic!("transparent TCP requires managed networking and private bubblewrap namespaces"); + } + if transparent_tcp_fd.is_empty() == (transparent_tcp && apply_seccomp_then_exec) { + panic!("invalid transparent TCP namespace handoff"); + } if command.is_empty() { panic!("No command specified to execute."); @@ -169,50 +193,38 @@ pub fn run_main() -> ! { panic!("failed to verify descriptor-backed bubblewrap mount: {err}"); } - let mut capability_header = [LINUX_CAPABILITY_VERSION_3, 0]; - let mut capability_sets = [[0_u32; 3]; 2]; - // SAFETY: capability ABI version 3 uses a [version, pid] header and - // two [effective, permitted, inheritable] capability-set entries. - let result = unsafe { - libc::syscall( - libc::SYS_capget, - capability_header.as_mut_ptr(), - capability_sets.as_mut_ptr(), - ) - }; - if result < 0 { - panic!( - "failed to verify Linux sandbox capabilities: {}", - std::io::Error::last_os_error() - ); - } - if capability_sets - .into_iter() - .any(|[effective, permitted, _]| effective != 0 || permitted != 0) - { + let caps = rustix::thread::capabilities(None) + .unwrap_or_else(|err| panic!("failed to verify Linux sandbox capabilities: {err}")); + if !caps.effective.is_empty() || !caps.permitted.is_empty() { panic!("Linux sandbox retained effective or permitted capabilities"); } - if allow_network_for_proxy { + let socks_bridge = 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; + activate_proxy_routes_in_netns(spec) + .unwrap_or_else(|err| panic!("error activating Linux proxy routing bridge: {err}")) + } else { + None + }; if let Err(e) = apply_sandbox_policies_to_current_thread( &file_system_sandbox_policy, network_sandbox_policy, &sandbox_policy_cwd, /*apply_landlock_fs*/ false, managed_network.as_ref(), - proxy_routing_active, + allow_network_for_proxy.then_some(routing), ) { panic!("error applying Linux sandbox restrictions: {e:?}"); } + // Keep the TUN and resolver descriptors in the reaper. Start networking + // threads only after the single-threaded workload fork. + let transport = transparent_tcp.then(|| { + Relay::prepare(transparent_tcp_fd, socks_bridge) + .unwrap_or_else(|err| panic!("failed to prepare transparent TCP: {err}")) + }); let signal_mask = ForwardedSignalMask::block(); let command_pid = unsafe { libc::fork() }; if command_pid < 0 { @@ -221,12 +233,24 @@ pub fn run_main() -> ! { } if command_pid == 0 { + if let Some((relay, mut reader)) = transport { + drop(relay); + reader + .read_exact(&mut [0]) + .unwrap_or_else(|err| panic!("transparent TCP relay failed to start: {err}")); + } reset_forwarded_signal_handlers_to_default(); signal_mask.restore(); exec_or_panic(command); } let signal_forwarders = install_bwrap_signal_forwarders(command_pid); + if let Some((relay, reader)) = transport { + drop(reader); + relay + .serve() + .unwrap_or_else(|err| panic!("failed to start transparent TCP relay: {err}")); + } signal_mask.restore(); loop { let mut status = 0; @@ -255,7 +279,7 @@ pub fn run_main() -> ! { &sandbox_policy_cwd, /*apply_landlock_fs*/ false, managed_network.as_ref(), - /*proxy_routing_active*/ false, + /*proxy_routing*/ None, ) { panic!("error applying Linux sandbox restrictions: {e:?}"); } @@ -267,10 +291,11 @@ pub fn run_main() -> ! { // sandboxed environment to apply seccomp. This path never falls back // to legacy Landlock on failure. let (proxy_route_spec, proxy_controls) = if let Some(context) = managed_network.as_ref() { - // Older explicit-proxy callers may omit the port list. - let ports = - (!context.loopback_ports.is_empty()).then_some(context.loopback_ports.as_slice()); - let (proxy_route_spec, controls) = prepare_host_proxy_route_spec(ports) + // Older explicit-proxy callers may omit ports. The new transparent + // switch always requires the executor's trusted ingress port list. + let ports = (transparent_tcp || !context.loopback_ports.is_empty()) + .then_some(context.loopback_ports.as_slice()); + let (proxy_route_spec, controls) = prepare_host_proxy_route_spec(ports, routing) .unwrap_or_else(|err| panic!("failed to prepare host proxy routing bridge: {err}")); (Some(proxy_route_spec), controls) } else { @@ -317,6 +342,7 @@ pub fn run_main() -> ! { options, inner, proxy_controls, + transparent_tcp, ); } @@ -327,7 +353,7 @@ pub fn run_main() -> ! { &sandbox_policy_cwd, /*apply_landlock_fs*/ true, managed_network.as_ref(), - /*proxy_routing_active*/ false, + /*proxy_routing*/ None, ) { panic!("error applying legacy Linux sandbox restrictions: {e:?}"); } @@ -399,13 +425,21 @@ fn run_bwrap_with_proc_fallback( mut options: BwrapOptions, inner: Vec, proxy_controls: Vec, + transparent_tcp: bool, ) -> ! { let command_cwd = command_cwd.unwrap_or(sandbox_policy_cwd); // Select before the preflight, whose synthetic policy omits writable roots. - initialize_bwrap_launcher(file_system_sandbox_policy, sandbox_policy_cwd); + initialize_bwrap_launcher( + file_system_sandbox_policy, + sandbox_policy_cwd, + transparent_tcp, + ); - if options.mount_proc + // Transparent TCP must mount procfs for the private PID namespace. Let the + // real bwrap launch fail if it cannot, since a root bind can expose host procfs. + if !transparent_tcp + && options.mount_proc && !preflight_proc_mount_support(options) .unwrap_or_else(|err| exit_with_bwrap_build_error(err)) { @@ -414,6 +448,12 @@ fn run_bwrap_with_proc_fallback( options.mount_proc = false; } + let tcp_files = transparent_tcp.then(|| { + let files = crate::tcp_namespace::create() + .unwrap_or_else(|err| panic!("failed to create private TCP namespace: {err}")); + options.network_mode = BwrapNetworkMode::PreparedProxy; + files + }); let mut bwrap_args = build_bwrap_argv( inner, file_system_sandbox_policy, @@ -422,6 +462,15 @@ fn run_bwrap_with_proc_fallback( options, ) .unwrap_or_else(|err| exit_with_bwrap_build_error(err)); + if let Some(files) = tcp_files { + crate::tcp_net_setup::prepare( + files, + &mut bwrap_args, + file_system_sandbox_policy, + sandbox_policy_cwd, + ) + .unwrap_or_else(|err| panic!("failed to prepare transparent TCP namespace: {err}")); + } bwrap_args.preserved_files.extend(proxy_controls); apply_inner_command_argv0(&mut bwrap_args.args); run_or_exec_bwrap(bwrap_args); diff --git a/codex-rs/linux-sandbox/src/managed_tcp.rs b/codex-rs/linux-sandbox/src/managed_tcp.rs index ddb79fba20..272850254c 100644 --- a/codex-rs/linux-sandbox/src/managed_tcp.rs +++ b/codex-rs/linux-sandbox/src/managed_tcp.rs @@ -1,13 +1,10 @@ -//! Translate a TUN worker’s TCP destination to the original hostname or literal IP -//! for the managed SOCKS5 bridge. - +//! Run the bundled TCP stack in the command's private network namespace. use crate::managed_tcp_dns::Names; -use crate::proxy_routing::proxy_bidirectional; +use rustix::net::sockopt; use socks5_protocol::Address; use socks5_protocol::AuthMethod; use socks5_protocol::AuthRequest; use socks5_protocol::AuthResponse; -use socks5_protocol::Command as SocksCommand; use socks5_protocol::CommandReply; use socks5_protocol::CommandRequest; use socks5_protocol::CommandResponse; @@ -16,53 +13,102 @@ use socks5_protocol::sync::FromIO; use std::io; use std::io::Write; use std::net::SocketAddr; +use std::net::TcpListener; use std::net::TcpStream; +use std::net::UdpSocket; +use std::os::fd::OwnedFd; +use std::sync::Arc; use std::sync::Mutex; +use std::thread; use std::time::Duration; +#[path = "managed_tcp_pending.rs"] +mod pending; +#[path = "managed_tcp_stack.rs"] +mod stack; + const TIMEOUT: Duration = Duration::from_secs(10); -fn relay( - mut client: TcpStream, +pub(super) struct Relay { + tun: OwnedFd, + ready: std::io::PipeWriter, proxy: SocketAddr, - names: &Mutex, -) -> socks5_protocol::Result<()> { - client.set_read_timeout(Some(TIMEOUT))?; - client.set_write_timeout(Some(TIMEOUT))?; - Version::read_from(&mut client)?; - let offered = AuthRequest::read_from(&mut client)?; - if !offered.0.contains(&AuthMethod::Noauth) { - return Err(io::Error::other("unsupported SOCKS greeting").into()); - } - Version::V5.write_to(&mut client)?; - AuthResponse::new(AuthMethod::Noauth).write_to(&mut client)?; + dns_udp: UdpSocket, + dns_tcp: TcpListener, +} - let upstream = match connect(&mut client, proxy, names) { - Ok(stream) => stream, - Err(err) => { - let denied = CommandResponse::reply_error(CommandReply::ConnectionNotAllowedByRuleset); - let _ = write_frame(&mut client, &denied); - return Err(err); +impl Relay { + pub(super) fn prepare( + fds: Vec, + proxy: Option, + ) -> io::Result<(Self, std::io::PipeReader)> { + let proxy = + proxy.ok_or_else(|| io::Error::other("transparent TCP requires managed SOCKS5"))?; + let [tun, udp, tcp] = crate::tcp_net_setup::receive(fds)?; + let (reader, ready) = std::io::pipe()?; + Ok(( + Self { + tun, + ready, + proxy, + dns_udp: udp.into(), + dns_tcp: tcp.into(), + }, + reader, + )) + } + + pub(super) fn serve(mut self) -> io::Result<()> { + let names = Arc::new(Mutex::new(Names::default())); + crate::managed_tcp_dns::serve(Arc::clone(&names), self.dns_udp, self.dns_tcp)?; + let (started, readiness) = std::sync::mpsc::sync_channel(1); + thread::Builder::new().spawn(move || { + // This is the namespace reaper. Losing the data path must end the workload. + if let Ok(Err(error)) = + std::panic::catch_unwind(|| stack::run(self.tun, self.proxy, names, started)) + { + eprintln!("transparent TCP stack exited: {error}"); + } + std::process::exit(1); + })?; + + // The workload is blocked on the pipe. Complete a native handshake through + // the TUN and check that its socket identity is visible before releasing it. + // This reserved address is never issued by DNS or forwarded upstream. + let probe_target = SocketAddr::from(([198, 18, 0, 0], 9)); + let probe = TcpStream::connect_timeout(&probe_target, TIMEOUT)?; + let source = readiness + .recv_timeout(TIMEOUT) + .map_err(io::Error::other)??; + if source != probe.local_addr()? { + return Err(io::Error::other("unexpected readiness source")); } - }; - write_frame(&mut client, &CommandResponse::success(Address::default()))?; - client.set_read_timeout(None)?; - client.set_write_timeout(None)?; - upstream.set_read_timeout(None)?; - upstream.set_write_timeout(None)?; - Ok(proxy_bidirectional(upstream, client)?) + self.ready.write_all(&[1]) + } +} + +pub(super) fn accept_with_backoff(listener: &TcpListener) -> io::Result { + loop { + let error = match listener.accept() { + Ok((stream, _)) => return Ok(stream), + Err(error) => error, + }; + match error.raw_os_error() { + Some(libc::EINTR | libc::ECONNABORTED) => continue, + Some(libc::EMFILE | libc::ENFILE | libc::ENOBUFS | libc::ENOMEM) => { + thread::sleep(Duration::from_millis(100)) + } + _ => return Err(error), + } + } } fn connect( - client: &mut impl io::Read, + source: SocketAddr, + target: SocketAddr, proxy: SocketAddr, names: &Mutex, ) -> socks5_protocol::Result { - let request = CommandRequest::read_from(client)?; - let (SocksCommand::Connect, Address::SocketAddr(target)) = (request.command, request.address) - else { - return Err(io::Error::other("expected TCP CONNECT to a TUN destination IP").into()); - }; let destination = match names .lock() .map_err(|_| io::Error::other("DNS name table is unavailable"))? @@ -72,6 +118,7 @@ fn connect( None => Address::SocketAddr(target), }; + let owner = pending::Owner::capture(source, target)?; let mut upstream = TcpStream::connect_timeout(&proxy, TIMEOUT)?; upstream.set_read_timeout(Some(TIMEOUT))?; upstream.set_write_timeout(Some(TIMEOUT))?; @@ -84,18 +131,23 @@ fn connect( write_frame(&mut upstream, &CommandRequest::connect(destination))?; // The trusted proxy owns the approval deadline. The relay's handshake // timeout applies to framing and negotiation, not the human decision. - upstream.set_read_timeout(None)?; + if let Err(error) = owner.wait_for_reply(&upstream) { + // An abort wakes both copies in the host bridge while approval is outstanding. + let _ = sockopt::set_socket_linger(&upstream, Some(Duration::ZERO)); + return Err(error.into()); + } + upstream.set_read_timeout(Some(TIMEOUT))?; let reply = CommandResponse::read_from(&mut upstream)?; if reply.reply != CommandReply::Succeeded || matches!(reply.address, Address::Domain(ref name, _) if name.is_empty()) { return Err(io::Error::other("invalid managed SOCKS5 success reply").into()); } + upstream.set_nonblocking(true)?; Ok(upstream) } -// Buffer the codec's field-by-field output before calling write_all. A denial -// reply is best effort; the authorization decision does not depend on its delivery. +// Emit the codec's field-by-field CONNECT frame in one write. fn write_frame(stream: &mut TcpStream, message: &impl FromIO) -> socks5_protocol::Result<()> { let mut bytes = Vec::new(); message.write_to(&mut bytes)?; diff --git a/codex-rs/linux-sandbox/src/managed_tcp_dns.rs b/codex-rs/linux-sandbox/src/managed_tcp_dns.rs index 64c6aaa318..bad7d1a887 100644 --- a/codex-rs/linux-sandbox/src/managed_tcp_dns.rs +++ b/codex-rs/linux-sandbox/src/managed_tcp_dns.rs @@ -16,9 +16,18 @@ use hickory_proto::serialize::binary::BinDecoder; use hickory_proto::serialize::binary::BinEncodable; use indexmap::IndexSet; use std::io; +use std::io::Read; +use std::io::Write; use std::net::IpAddr; use std::net::Ipv4Addr; use std::net::Ipv6Addr; +use std::net::TcpListener; +use std::net::TcpStream; +use std::net::UdpSocket; +use std::sync::Arc; +use std::sync::Mutex; +use std::thread; +use std::time::Duration; const MAX_NAMES: usize = 8192; const V4_BASE: u32 = u32::from_be_bytes([198, 18, 0, 0]); @@ -112,6 +121,57 @@ impl Names { } } +pub(super) fn serve(names: Arc>, udp: UdpSocket, tcp: TcpListener) -> io::Result<()> { + let udp_names = Arc::clone(&names); + thread::Builder::new().spawn(move || { + let mut buf = [0; 4096]; + while let Ok((n, peer)) = udp.recv_from(&mut buf) { + let reply = udp_names + .lock() + .ok() + .and_then(|mut table| table.answer(&buf[..n])); + if let Some(reply) = reply { + let _ = udp.send_to(&reply, peer); + } + } + })?; + // Fixed workers bound concurrent DNS-over-TCP exchanges. Some resolvers + // pipeline A and AAAA queries on the same connection. + for _ in 0..4 { + let listener = tcp.try_clone()?; + let names = Arc::clone(&names); + thread::Builder::new().spawn(move || { + while let Ok(stream) = crate::managed_tcp::accept_with_backoff(&listener) { + let _ = answer_tcp(stream, &names); + } + })?; + } + Ok(()) +} + +fn answer_tcp(mut stream: TcpStream, names: &Mutex) -> io::Result<()> { + let timeout = Some(Duration::from_secs(2)); + stream.set_read_timeout(timeout)?; + stream.set_write_timeout(timeout)?; + let mut buf = [0; 4096]; + for _ in 0..32 { + let mut size = [0; 2]; + stream.read_exact(&mut size)?; + let query = buf + .get_mut(..usize::from(u16::from_be_bytes(size))) + .ok_or_else(|| io::Error::other("oversized DNS query"))?; + stream.read_exact(query)?; + let reply = names + .lock() + .map_err(|_| io::Error::other("DNS name table is unavailable"))? + .answer(query) + .ok_or_else(|| io::Error::other("invalid DNS query"))?; + stream.write_all(&(reply.len() as u16).to_be_bytes())?; + stream.write_all(&reply)?; + } + Ok(()) +} + #[cfg(test)] #[path = "managed_tcp_tests.rs"] mod tests; diff --git a/codex-rs/linux-sandbox/src/managed_tcp_pending.rs b/codex-rs/linux-sandbox/src/managed_tcp_pending.rs new file mode 100644 index 0000000000..2c0836bbdb --- /dev/null +++ b/codex-rs/linux-sandbox/src/managed_tcp_pending.rs @@ -0,0 +1,140 @@ +//! Pin the native socket inode before asking for approval. Linux keeps the inode +//! for a live reader after shutdown(SHUT_WR), and clears it on the last close. + +use std::collections::HashSet; +use std::fs::File; +use std::io; +use std::io::BufRead; +use std::io::BufReader; +use std::net::IpAddr; +use std::net::Ipv6Addr; +use std::net::SocketAddr; +use std::net::TcpStream; +use std::sync::Mutex; +use std::time::Duration; +use std::time::Instant; + +const INTERVAL: Duration = Duration::from_millis(500); +const TCP4: &str = "/proc/self/net/tcp"; +const TCP6: &str = "/proc/self/net/tcp6"; + +pub(super) struct Owner { + inode: u64, + captured: Instant, + table: &'static str, +} + +fn find_inode( + table: &str, + mut matches: impl FnMut(&str, &str, u64) -> bool, +) -> io::Result> { + let mut reader = BufReader::new(File::open(table)?); + let mut line = String::new(); + reader.read_line(&mut line)?; + if !line.contains("local_address") || !line.contains("inode") { + return Err(io::Error::other("missing native TCP socket table")); + } + loop { + line.clear(); + if reader.read_line(&mut line)? == 0 { + return Ok(None); + } + let mut fields = line.split_ascii_whitespace().skip(1); + let (Some(local), Some(remote), Some(inode)) = + (fields.next(), fields.next(), fields.nth(6)) + else { + return Err(io::Error::other("unexpected native TCP socket record")); + }; + let inode = inode.parse::().map_err(io::Error::other)?; + if inode != 0 && matches(local, remote, inode) { + return Ok(Some(inode)); + } + } +} + +fn v6_key(addr: SocketAddr) -> String { + let ip: Ipv6Addr = match addr.ip() { + IpAddr::V4(v4) => v4.to_ipv6_mapped(), + IpAddr::V6(v6) => v6, + }; + // procfs renders each 32-bit word in native byte order. + let address: String = ip + .octets() + .as_chunks::<4>() + .0 + .iter() + .map(|word| format!("{:08X}", u32::from_ne_bytes(*word))) + .collect(); + format!("{address}:{:04X}", addr.port()) +} + +impl Owner { + pub(super) fn capture(source: SocketAddr, target: SocketAddr) -> io::Result { + let lookup = |table, local: String, remote: String| { + let inode = find_inode(table, |from, to, _| from == local && to == remote)?; + Ok::<_, io::Error>(inode.map(|inode| Self { + inode, + table, + captured: Instant::now(), + })) + }; + if let (SocketAddr::V4(src), SocketAddr::V4(dst)) = (source, target) { + let key = |addr: std::net::SocketAddrV4| { + format!( + "{:08X}:{:04X}", + u32::from_ne_bytes(addr.ip().octets()), + addr.port() + ) + }; + if let Some(owner) = lookup(TCP4, key(src), key(dst))? { + return Ok(owner); + } + } + lookup(TCP6, v6_key(source), v6_key(target))?.ok_or(io::ErrorKind::ConnectionAborted.into()) + } + + fn still_owned(&self) -> io::Result { + // Only slow approvals need polling. Share one bounded snapshot per family. + type Snapshot = Mutex>)>>; + static V4: Snapshot = Mutex::new(None); + static V6: Snapshot = Mutex::new(None); + let cache = if self.table == TCP4 { &V4 } else { &V6 }; + let mut cache = cache + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if cache + .as_ref() + .is_none_or(|(sampled, _)| *sampled < self.captured || sampled.elapsed() >= INTERVAL) + { + let sampled = Instant::now(); + let mut inodes = HashSet::new(); + let overflow = find_inode(self.table, |_, _, inode| { + inodes.insert(inode); + inodes.len() > 16_384 + })?; + *cache = Some((sampled, overflow.is_none().then_some(inodes))); + } + if let Some((_, Some(inodes))) = &*cache { + return Ok(inodes.contains(&self.inode)); + } + drop(cache); + Ok(find_inode(self.table, |_, _, inode| inode == self.inode)?.is_some()) + } + + pub(super) fn wait_for_reply(&self, stream: &TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(INTERVAL))?; + loop { + match stream.peek(&mut [0]) { + Err(e) + if matches!( + e.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut + ) => {} + result => return result.map(|_| ()), + } + if !self.still_owned()? { + return Err(io::ErrorKind::ConnectionAborted.into()); + } + } + } +} diff --git a/codex-rs/linux-sandbox/src/managed_tcp_relay_tests.rs b/codex-rs/linux-sandbox/src/managed_tcp_relay_tests.rs index 5e37274acb..a6b9eee985 100644 --- a/codex-rs/linux-sandbox/src/managed_tcp_relay_tests.rs +++ b/codex-rs/linux-sandbox/src/managed_tcp_relay_tests.rs @@ -1,72 +1,38 @@ #![cfg(test)] -//! Verify rejected TUN requests cannot open an upstream connection. +//! A denied managed CONNECT never yields a stream for the TCP stack. use super::*; use pretty_assertions::assert_eq; use std::io::Read; use std::io::Write; use std::net::Ipv4Addr; -use std::net::TcpListener; -use std::thread; #[test] -fn invalid_tun_requests_never_open_upstream() { - let proxy = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); - let addr = proxy.local_addr().unwrap(); - for request in [ - [5, 3, 0, 1, 127, 0, 0, 1, 0, 53], - [5, 2, 0, 1, 127, 0, 0, 1, 0, 80], - [5, 1, 1, 1, 127, 0, 0, 1, 0, 80], - [5, 1, 0, 3, 3, b'd', b'b', b'x', 0, 80], - [5, 1, 0, 1, 198, 18, 0, 99, 1, 187], - ] { - assert!(connect(&mut request.as_slice(), addr, &Mutex::new(Names::default())).is_err()); - } - proxy.set_nonblocking(true).unwrap(); - assert_eq!( - proxy.accept().unwrap_err().kind(), - io::ErrorKind::WouldBlock - ); -} - -#[test] -fn denied_socks_connect_never_forwards_pipelined_payload() { +fn denied_socks_connect_never_returns_an_upstream() { let proxy = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let proxy_addr = proxy.local_addr().unwrap(); + let native_listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let target = native_listener.local_addr().unwrap(); + let native = TcpStream::connect(target).unwrap(); + let source = native.local_addr().unwrap(); + let task = + thread::spawn(move || connect(source, target, proxy_addr, &Mutex::new(Names::default()))); rustix::net::sockopt::set_socket_timeout( &proxy, rustix::net::sockopt::Timeout::Recv, Some(TIMEOUT), ) .unwrap(); - let proxy_addr = proxy.local_addr().unwrap(); - let incoming = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); - let mut client = TcpStream::connect(incoming.local_addr().unwrap()).unwrap(); - let (server, _) = incoming.accept().unwrap(); - let task = thread::spawn(move || relay(server, proxy_addr, &Mutex::new(Names::default()))); - - // A malicious client can send application data before either handshake completes. - let connect = [5, 1, 0, 1, 192, 0, 2, 7, 0x15, 0x38]; - client.write_all(&[5, 2, 2, 0]).unwrap(); - client.write_all(&connect).unwrap(); - client.write_all(b"private marker").unwrap(); - client.shutdown(std::net::Shutdown::Write).unwrap(); - let (mut upstream, _) = proxy.accept().unwrap(); upstream.set_read_timeout(Some(TIMEOUT)).unwrap(); let mut greeting = [0; 3]; upstream.read_exact(&mut greeting).unwrap(); assert_eq!(greeting, [5, 1, 0]); upstream.write_all(&[5, 0]).unwrap(); - let mut request = [0; 10]; - upstream.read_exact(&mut request).unwrap(); - assert_eq!(request, connect); + let request = CommandRequest::read_from(&mut upstream).unwrap(); + assert!(matches!(request.address, Address::SocketAddr(address) if address == target)); upstream.write_all(&[5, 2, 0, 1, 0, 0, 0, 0, 0, 0]).unwrap(); - - let mut response = [0; 12]; // greeting and refusal sent to the client - client.read_exact(&mut response).unwrap(); - assert_eq!(&response[..4], &[5, 0, 5, 2]); - let mut byte = [0]; - assert_eq!(upstream.read(&mut byte).unwrap(), 0); + assert_eq!(upstream.read(&mut [0]).unwrap(), 0); assert!(task.join().unwrap().is_err()); } diff --git a/codex-rs/linux-sandbox/src/managed_tcp_stack.rs b/codex-rs/linux-sandbox/src/managed_tcp_stack.rs new file mode 100644 index 0000000000..3a39ccc6c6 --- /dev/null +++ b/codex-rs/linux-sandbox/src/managed_tcp_stack.rs @@ -0,0 +1,344 @@ +//! Drive a bounded set of smoltcp sockets on the TUN inherited from namespace setup. +//! The kernel still owns the native client sockets. Only the managed SOCKS bridge +//! can supply an upstream stream, and only after it has approved the destination. +use super::Names; +use etherparse::IpNumber; +use etherparse::IpSlice; +use etherparse::Ipv6ExtensionSlice; +use etherparse::TcpSlice; +use smoltcp::iface::Config; +use smoltcp::iface::Interface; +use smoltcp::iface::PollResult; +use smoltcp::iface::SocketHandle; +use smoltcp::iface::SocketSet; +use smoltcp::phy::Device; +use smoltcp::phy::DeviceCapabilities; +use smoltcp::phy::Medium; +use smoltcp::phy::RxToken; +use smoltcp::phy::TunTapInterface; +use smoltcp::socket::tcp::Socket; +use smoltcp::socket::tcp::SocketBuffer; +use smoltcp::socket::tcp::State; +use smoltcp::time::Instant; +use smoltcp::wire::HardwareAddress; +use smoltcp::wire::IpCidr; +use std::collections::HashMap; +use std::io; +use std::io::Read; +use std::io::Write; +use std::net::IpAddr; +use std::net::Ipv4Addr; +use std::net::Ipv6Addr; +use std::net::Shutdown; +use std::net::SocketAddr; +use std::net::TcpStream; +use std::os::fd::AsRawFd; +use std::os::fd::IntoRawFd; +use std::os::fd::OwnedFd; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::mpsc; +use std::thread; + +const MTU: usize = 1500; +const MAX_FLOWS: usize = 128; +const WINDOW: usize = 32 * 1024; + +struct Packet<'a>(&'a [u8]); +impl RxToken for Packet<'_> { + fn consume R>(self, f: F) -> R { + f(self.0) + } +} + +// Give smoltcp exactly the frame admitted below. Its ordinary TUN device +// owns transmission, including backpressure and MTU handling. +struct Ingress<'a> { + device: &'a mut TunTapInterface, + packet: Option<&'a [u8]>, +} + +impl Device for Ingress<'_> { + type RxToken<'a> + = Packet<'a> + where + Self: 'a; + type TxToken<'a> + = ::TxToken<'a> + where + Self: 'a; + + fn receive(&mut self, now: Instant) -> Option<(Self::RxToken<'_>, Self::TxToken<'_>)> { + Some((Packet(self.packet.take()?), self.device.transmit(now)?)) + } + fn transmit(&mut self, now: Instant) -> Option> { + self.device.transmit(now) + } + fn capabilities(&self) -> DeviceCapabilities { + self.device.capabilities() + } +} + +type Endpoints = (SocketAddr, SocketAddr); + +// Allocate a listener only for an opening SYN. smoltcp validates the complete +// packet, tracks sequence numbers, and owns retransmission and flow control. +fn tcp_endpoints(packet: &[u8]) -> Option<(Endpoints, bool)> { + let ip = IpSlice::from_slice(packet).ok()?; + if ip.is_fragmenting_payload() || ip.payload_ip_number() != IpNumber::TCP { + return None; + } + // Match the extensions supported by the stack before allocating a listener. + let supported = match &ip { + IpSlice::Ipv4(v4) => v4.extensions().auth.is_none(), + IpSlice::Ipv6(v6) => v6.extensions().clone().into_iter().all(|ext| { + matches!(ext, Ipv6ExtensionSlice::HopByHop(header) if header.next_header() == IpNumber::TCP) + }), + }; + if !supported { + return None; + } + let tcp = TcpSlice::from_slice(ip.payload().payload).ok()?; + let endpoints = ( + SocketAddr::new(ip.source_addr(), tcp.source_port()), + SocketAddr::new(ip.destination_addr(), tcp.destination_port()), + ); + Some(( + endpoints, + tcp.syn() && !tcp.ack() && !tcp.rst() && !tcp.fin() && tcp.destination_port() != 0, + )) +} + +enum Session { + Handshake, + Pending(mpsc::Receiver), + Open { + upstream: TcpStream, + write_closed: bool, + read_closed: bool, + }, + Done, +} + +impl Session { + fn pump(&mut self, socket: &mut Socket<'_>, poll: &mut Vec) -> io::Result<()> { + if let Self::Pending(result) = self { + match result.try_recv() { + Ok(upstream) => { + *self = Self::Open { + upstream, + write_closed: false, + read_closed: false, + } + } + Err(mpsc::TryRecvError::Empty) => return Ok(()), + Err(mpsc::TryRecvError::Disconnected) => { + return Err(io::ErrorKind::ConnectionAborted.into()); + } + } + } + let Self::Open { + upstream, + write_closed, + read_closed, + } = self + else { + return Ok(()); + }; + if socket.can_recv() { + socket + .recv(|bytes| transfer(upstream.write(bytes))) + .map_err(io::Error::other)??; + } + if !socket.may_recv() && !*write_closed { + upstream.shutdown(Shutdown::Write)?; + *write_closed = true; + } + if !*read_closed && socket.can_send() { + let n = socket + .send(|space| transfer(upstream.read(space))) + .map_err(io::Error::other)??; + if n == Some(0) { + socket.close(); + *read_closed = true; + } + } + let mut events = 0; + if !*read_closed && socket.can_send() { + events |= libc::POLLIN; + } + if socket.can_recv() { + events |= libc::POLLOUT; + } + if events != 0 { + poll.push(libc::pollfd { + fd: upstream.as_raw_fd(), + events, + revents: 0, + }); + } + Ok(()) + } +} + +fn transfer(result: io::Result) -> (usize, io::Result>) { + match result { + Ok(n) => (n, Ok(Some(n))), + Err(e) + if matches!( + e.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted + ) => + { + (0, Ok(None)) + } + Err(e) => (0, Err(e)), + } +} + +fn interface(tun: &mut TunTapInterface) -> io::Result { + let mut config = Config::new(HardwareAddress::Ip); + config.random_seed = rand::random(); + let mut iface = Interface::new(config, tun, Instant::ZERO); + let v4 = Ipv4Addr::new(192, 0, 2, 2); + let v6 = Ipv6Addr::LOCALHOST; + let mut addresses_fit = true; + iface.update_ip_addrs(|ips| { + for ip in [IpAddr::V4(v4), IpAddr::V6(v6)] { + addresses_fit &= ips + .push(IpCidr::new(ip.into(), if ip.is_ipv4() { 32 } else { 128 })) + .is_ok(); + } + }); + if !addresses_fit { + return Err(io::Error::other("insufficient TCP interface addresses")); + } + iface + .routes_mut() + .add_default_ipv4_route(v4) + .map_err(io::Error::other)?; + iface + .routes_mut() + .add_default_ipv6_route(v6) + .map_err(io::Error::other)?; + iface.set_any_ip(true); + Ok(iface) +} + +pub(super) fn run( + fd: OwnedFd, + proxy: SocketAddr, + names: Arc>, + started: mpsc::SyncSender>, +) -> io::Result<()> { + use rustix::fs::OFlags; + use rustix::fs::fcntl_getfl; + use rustix::fs::fcntl_setfl; + fcntl_setfl(&fd, fcntl_getfl(&fd)? | OFlags::NONBLOCK)?; + // from_fd takes ownership of the descriptor opened in the private namespace. + let mut tun = TunTapInterface::from_fd(fd.into_raw_fd(), Medium::Ip, MTU)?; + let mut iface = interface(&mut tun)?; + let clock = std::time::Instant::now(); + let mut started = Some(started); + let mut sockets = SocketSet::new(Vec::new()); + let mut flows: HashMap = HashMap::new(); + let permits = Arc::new(async_lock::Semaphore::new(MAX_FLOWS)); + let mut poll = Vec::with_capacity(MAX_FLOWS + 1); + loop { + let now = Instant::from_micros(clock.elapsed().as_micros() as i64); + iface.poll_maintenance(now); + if let Some((rx, _)) = tun.receive(now) { + rx.consume(|packet| -> io::Result<()> { + if packet.is_empty() { + return Err(io::ErrorKind::UnexpectedEof.into()); + } + let Some((key, syn)) = tcp_endpoints(packet) else { + return Ok(()); + }; + if syn && !flows.contains_key(&key) { + if flows.len() == MAX_FLOWS { + return Ok(()); // The kernel retries when a slot opens. + } + let buffer = || SocketBuffer::new(vec![0; WINDOW]); + let mut native = Socket::new(buffer(), buffer()); + native.set_timeout(Some(super::TIMEOUT.into())); + native.listen(key.1).map_err(io::Error::other)?; + flows.insert(key, (sockets.add(native), Session::Handshake)); + } + iface.poll_ingress_single( + now, + &mut Ingress { + device: &mut tun, + packet: Some(packet), + }, + &mut sockets, + ); + Ok(()) + })?; + } + iface.poll_egress(now, &mut tun, &mut sockets); + poll.clear(); + poll.push(libc::pollfd { + fd: tun.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }); + let mut pending = false; + flows.retain(|&(src, dst), (handle, session)| { + let native = sockets.get_mut::(*handle); + if !native.is_active() { + sockets.remove(*handle); + return false; + } + if matches!(session, Session::Handshake) + && matches!(native.state(), State::Established | State::CloseWait) + { + native.set_timeout(None); + if let Some(started) = started.take() { + let result = if dst == SocketAddr::from(([198, 18, 0, 0], 9)) { + super::pending::Owner::capture(src, dst).map(|_| src) + } else { + Err(io::Error::other("unexpected readiness destination")) + }; + let _ = started.send(result); + native.close(); + *session = Session::Done; + return true; + } + let Some(permit) = permits.try_acquire_arc() else { + pending = true; + return true; + }; + let (reply, result) = mpsc::sync_channel(1); + let names = Arc::clone(&names); + let _ = thread::Builder::new().spawn(move || { + let _permit = permit; + if let Ok(upstream) = super::connect(src, dst, proxy, &names) { + let _ = reply.send(upstream); + } + }); + *session = Session::Pending(result); + } + if session.pump(native, &mut poll).is_err() { + native.abort(); + *session = Session::Done; + } + pending |= matches!(session, Session::Pending(_)); + true + }); + while iface.poll_egress(now, &mut tun, &mut sockets) == PollResult::SocketStateChanged {} + let delay = iface + .poll_delay(now, &sockets) + .map(|d| d.total_millis().min(i32::MAX as u64) as i32); + let timeout = if pending { + delay.unwrap_or(25).min(25) + } else { + delay.unwrap_or(-1) + }; + // SAFETY: all descriptors in poll are owned by tun or live entries in flows. + let result = unsafe { libc::poll(poll.as_mut_ptr(), poll.len() as _, timeout) }; + if result < 0 && io::Error::last_os_error().kind() != io::ErrorKind::Interrupted { + return Err(io::Error::last_os_error()); + } + } +} diff --git a/codex-rs/linux-sandbox/src/proxy_routing.rs b/codex-rs/linux-sandbox/src/proxy_routing.rs index 908d682d8c..ff79b5bcf1 100644 --- a/codex-rs/linux-sandbox/src/proxy_routing.rs +++ b/codex-rs/linux-sandbox/src/proxy_routing.rs @@ -3,6 +3,8 @@ use crate::proxy_lifecycle::harden_bridge_process; use crate::proxy_lifecycle::move_fd_above_stdio; use crate::proxy_lifecycle::receive_listener; use crate::proxy_lifecycle::send_listener; +use crate::seccomp::ProxyRouting; +use async_lock::Semaphore; use codex_network_proxy::PROXY_ATTRIBUTION_TOKEN_ENV_KEY; use codex_network_proxy::write_attribution_frame; use serde::Deserialize; @@ -26,7 +28,6 @@ use std::os::unix::net::UnixStream; use std::sync::Arc; use std::sync::mpsc; use std::time::Duration; -use tokio::sync::Semaphore; use url::Url; const HOST_BRIDGE_READY: u8 = 1; @@ -58,6 +59,7 @@ struct ProxyRoutePlan { pub(crate) fn prepare_host_proxy_route_spec( port_allowlist: Option<&[u16]>, + routing: ProxyRouting, ) -> io::Result<(String, Vec)> { let (attribution_token, plan) = extract_attribution_token_and_plan(std::env::vars().collect()); // SAFETY: the sandbox helper is single-threaded here, before it forks bridge workers or @@ -96,6 +98,7 @@ pub(crate) fn prepare_host_proxy_route_spec( } let control = spawn_host_bridge( route.endpoint, + routing, attribution_token.as_deref(), &mut control_by_endpoint, )?; @@ -128,7 +131,9 @@ fn extract_attribution_token_and_plan( (attribution_token, plan) } -pub(crate) fn activate_proxy_routes_in_netns(serialized_spec: &str) -> io::Result<()> { +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() { @@ -179,6 +184,7 @@ pub(crate) fn activate_proxy_routes_in_netns(serialized_spec: &str) -> io::Resul local_port_by_control.insert(route.control_fd, local_port); } + let mut socks_bridge = None; for route in spec.routes { let Some(local_port) = local_port_by_control.get(&route.control_fd) else { return Err(io::Error::other("missing proxy listener port")); @@ -195,6 +201,11 @@ pub(crate) fn activate_proxy_routes_in_netns(serialized_spec: &str) -> io::Resul format!("could not rewrite proxy URL for env key {}", route.env_key), )); }; + if route.env_key == "ALL_PROXY" + && Url::parse(&rewritten).is_ok_and(|url| matches!(url.scheme(), "socks5h" | "socks5")) + { + socks_bridge = Some(SocketAddr::from((Ipv4Addr::LOCALHOST, *local_port))); + } // SAFETY: this helper process is single-threaded at this point, and // env mutation happens before execing the user command. unsafe { @@ -202,7 +213,7 @@ pub(crate) fn activate_proxy_routes_in_netns(serialized_spec: &str) -> io::Resul } } - Ok(()) + Ok(socks_bridge) } fn plan_proxy_routes(env: &HashMap) -> ProxyRoutePlan { @@ -320,6 +331,7 @@ fn rewrite_proxy_env_value(proxy_url: &str, local_port: u16) -> Option { fn spawn_host_bridge( endpoint: SocketAddr, + routing: ProxyRouting, attribution_token: Option<&str>, inherited_controls: &mut BTreeMap, ) -> io::Result { @@ -338,7 +350,13 @@ fn spawn_host_bridge( // A worker must not retain another route's inner bootstrap endpoint. // Drop this child's inherited owners; the parent's copies are unaffected by fork. inherited_controls.clear(); - let result = run_host_bridge(endpoint, host_control, attribution_token, parent_pid); + let result = run_host_bridge( + endpoint, + routing, + host_control, + attribution_token, + parent_pid, + ); if result.is_err() { unsafe { libc::_exit(1) }; } @@ -365,9 +383,14 @@ impl ConnectionSlots { Self(Arc::new(Semaphore::new(MAX_CONNECTIONS))) } - pub(super) fn spawn(&self, serve: impl FnOnce() + Send + 'static) { - let Ok(permit) = Arc::clone(&self.0).try_acquire_owned() else { - return; + pub(super) fn spawn(&self, routing: ProxyRouting, serve: impl FnOnce() + Send + 'static) { + // Wait in the accept loop, before allocating another thread or upstream socket. + let permit = match routing { + ProxyRouting::TransparentTcp => self.0.acquire_arc_blocking(), + ProxyRouting::Explicit => match self.0.try_acquire_arc() { + Some(permit) => permit, + None => return, + }, }; let _ = std::thread::Builder::new().spawn(move || { let _permit = permit; @@ -378,6 +401,7 @@ impl ConnectionSlots { fn run_host_bridge( endpoint: SocketAddr, + routing: ProxyRouting, mut control: UnixStream, attribution_token: Option<&str>, parent_pid: libc::pid_t, @@ -396,7 +420,7 @@ fn run_host_bridge( loop { let (client_stream, _) = listener.accept()?; let attribution_token = attribution_token.clone(); - slots.spawn(move || { + slots.spawn(routing, move || { let mut tcp_stream = match TcpStream::connect(endpoint) { Ok(stream) => stream, Err(_) => return, @@ -431,7 +455,7 @@ fn bind_local_loopback_listener() -> io::Result { } } -fn ensure_loopback_interface_up() -> io::Result<()> { +pub(super) 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()); diff --git a/codex-rs/linux-sandbox/src/proxy_routing_tests.rs b/codex-rs/linux-sandbox/src/proxy_routing_tests.rs index 336024365d..2600166a11 100644 --- a/codex-rs/linux-sandbox/src/proxy_routing_tests.rs +++ b/codex-rs/linux-sandbox/src/proxy_routing_tests.rs @@ -11,6 +11,8 @@ use super::parse_loopback_proxy_endpoint; use super::plan_proxy_routes; use super::proxy_bidirectional; use super::rewrite_proxy_env_value; +use crate::seccomp::ProxyRouting; +use async_lock::Semaphore; use pretty_assertions::assert_eq; use std::collections::HashMap; use std::io::Read; @@ -22,7 +24,6 @@ use std::net::TcpListener; use std::net::TcpStream; use std::sync::Arc; use std::time::Duration; -use tokio::sync::Semaphore; #[tokio::test] async fn a_reset_on_either_side_releases_the_bridge_permit() { @@ -41,7 +42,7 @@ async fn a_reset_on_either_side_releases_the_bridge_permit() { (idle_socket, reset_socket) }; let (done, result) = tokio::sync::oneshot::channel(); - slots.spawn(move || { + slots.spawn(ProxyRouting::Explicit, move || { let _ = done.send(proxy_bidirectional(upstream, client)); }); rustix::net::sockopt::set_socket_linger(&reset_peer, Some(Duration::ZERO)).unwrap(); @@ -49,8 +50,7 @@ async fn a_reset_on_either_side_releases_the_bridge_permit() { let _permit = tokio::time::timeout(Duration::from_secs(5), slots.0.acquire()) .await - .expect("bridge must release its permit while the other peer is still open") - .unwrap(); + .expect("bridge must release its permit while the other peer is still open"); assert!(result.await.unwrap().is_err()); drop(idle_peer); } diff --git a/codex-rs/linux-sandbox/src/seccomp.rs b/codex-rs/linux-sandbox/src/seccomp.rs index 01a22ac7dd..210c1f4dc1 100644 --- a/codex-rs/linux-sandbox/src/seccomp.rs +++ b/codex-rs/linux-sandbox/src/seccomp.rs @@ -48,12 +48,12 @@ pub(crate) fn apply_sandbox_policies_to_current_thread( cwd: &Path, apply_landlock_fs: bool, managed_network: Option<&ManagedNetworkSandboxContext>, - proxy_routing_active: bool, + proxy_routing: Option, ) -> Result<()> { let network_seccomp_mode = network_seccomp_mode( network_sandbox_policy, managed_network.is_some(), - proxy_routing_active, + proxy_routing, ) .or_else(|| { // VM sockets can reach host services outside the filesystem sandbox. @@ -100,10 +100,16 @@ pub(crate) fn apply_sandbox_policies_to_current_thread( #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum NetworkSeccompMode { Restricted, - ProxyRouted, + 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, @@ -116,12 +122,12 @@ fn should_install_network_seccomp( fn network_seccomp_mode( network_sandbox_policy: NetworkSandboxPolicy, allow_network_for_proxy: bool, - proxy_routed_network: bool, + proxy_routed_network: Option, ) -> Option { if !should_install_network_seccomp(network_sandbox_policy, allow_network_for_proxy) { None - } else if proxy_routed_network { - Some(NetworkSeccompMode::ProxyRouted) + } else if let Some(routing) = proxy_routed_network { + Some(NetworkSeccompMode::ProxyRouted(routing)) } else { Some(NetworkSeccompMode::Restricted) } @@ -267,11 +273,11 @@ fn install_network_seccomp_filter_on_current_thread( 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 => { + 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; all other - // socket families remain denied. + // 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, @@ -294,14 +300,40 @@ fn install_network_seccomp_filter_on_current_thread( libc::AF_UNIX as u64, )?); } - let deny_non_ip_socket = SeccompRule::new(denied_socket_conditions)?; + 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, vec![deny_non_ip_socket]); + add_syscall_rules(&mut rules, libc::SYS_socket, socket_rules); add_syscall_rules( &mut rules, libc::SYS_socketpair, diff --git a/codex-rs/linux-sandbox/src/seccomp_tests.rs b/codex-rs/linux-sandbox/src/seccomp_tests.rs index a51fc68835..6e4f6a161c 100644 --- a/codex-rs/linux-sandbox/src/seccomp_tests.rs +++ b/codex-rs/linux-sandbox/src/seccomp_tests.rs @@ -3,6 +3,7 @@ #![cfg(test)] use super::NetworkSeccompMode; +use super::ProxyRouting; use super::install_network_seccomp_filter_on_current_thread; use super::network_seccomp_mode; use super::set_no_new_privs; @@ -17,7 +18,8 @@ use std::collections::BTreeMap; use test_case::test_case; #[test_case(NetworkSeccompMode::Restricted, libc::EPERM; "restricted")] -#[test_case(NetworkSeccompMode::ProxyRouted, libc::EPERM; "proxy_routed")] +#[test_case(NetworkSeccompMode::ProxyRouted(ProxyRouting::Explicit), libc::EPERM; "explicit_proxy")] +#[test_case(NetworkSeccompMode::ProxyRouted(ProxyRouting::TransparentTcp), libc::EPERM; "transparent_tcp")] #[test_case(NetworkSeccompMode::VmSocketRestricted, libc::ENOSYS; "vm_socket_only")] fn request_key_respects_network_policy(mode: NetworkSeccompMode, expected_errno: i32) { // Filters apply only to this thread, leaving the test runner unrestricted. @@ -103,9 +105,9 @@ fn managed_proxy_routes_use_proxy_routed_seccomp_mode() { network_seccomp_mode( NetworkSandboxPolicy::Enabled, /*allow_network_for_proxy*/ true, - /*proxy_routed_network*/ true, + Some(ProxyRouting::Explicit), ), - Some(NetworkSeccompMode::ProxyRouted) + Some(NetworkSeccompMode::ProxyRouted(ProxyRouting::Explicit)) ); } @@ -115,7 +117,7 @@ fn restricted_network_without_proxy_routing_uses_restricted_mode() { network_seccomp_mode( NetworkSandboxPolicy::Restricted, /*allow_network_for_proxy*/ false, - /*proxy_routed_network*/ false, + /*proxy_routed_network*/ None, ), Some(NetworkSeccompMode::Restricted) ); @@ -127,7 +129,7 @@ fn full_network_without_managed_proxy_skips_network_seccomp_mode() { network_seccomp_mode( NetworkSandboxPolicy::Enabled, /*allow_network_for_proxy*/ false, - /*proxy_routed_network*/ false, + /*proxy_routed_network*/ None, ), None ); @@ -149,7 +151,8 @@ fn syscall_result(nr: i64, arg0: i64) -> std::result::Result { #[cfg(target_arch = "x86_64")] #[test_case::test_case(NetworkSeccompMode::Restricted; "restricted")] -#[test_case::test_case(NetworkSeccompMode::ProxyRouted; "proxy_routed")] +#[test_case::test_case(NetworkSeccompMode::ProxyRouted(ProxyRouting::Explicit); "explicit_proxy")] +#[test_case::test_case(NetworkSeccompMode::ProxyRouted(ProxyRouting::TransparentTcp); "transparent_tcp")] #[test_case::test_case(NetworkSeccompMode::VmSocketRestricted; "vm_socket_restricted")] fn x32_restricted_syscalls_are_denied(mode: NetworkSeccompMode) { std::thread::spawn(move || { @@ -207,7 +210,8 @@ fn x32_restricted_syscalls_are_denied(mode: NetworkSeccompMode) { #[cfg(target_arch = "x86_64")] #[test_case::test_case(NetworkSeccompMode::Restricted; "restricted")] -#[test_case::test_case(NetworkSeccompMode::ProxyRouted; "proxy_routed")] +#[test_case::test_case(NetworkSeccompMode::ProxyRouted(ProxyRouting::Explicit); "explicit_proxy")] +#[test_case::test_case(NetworkSeccompMode::ProxyRouted(ProxyRouting::TransparentTcp); "transparent_tcp")] #[test_case::test_case(NetworkSeccompMode::VmSocketRestricted; "vm_socket_restricted")] fn x32_preserves_allowed_calls_and_socket_conditions(mode: NetworkSeccompMode) { std::thread::spawn(move || { @@ -215,13 +219,17 @@ fn x32_preserves_allowed_calls_and_socket_conditions(mode: NetworkSeccompMode) { NetworkSeccompMode::Restricted | NetworkSeccompMode::VmSocketRestricted => { libc::AF_UNIX } - NetworkSeccompMode::ProxyRouted => libc::AF_INET, + NetworkSeccompMode::ProxyRouted(_) => libc::AF_INET, }; - let allowed = [ + let mut allowed = vec![ (libc::SYS_getpid, 0), (libc::SYS_socket, i64::from(socket_domain)), (libc::SYS_socketpair, i64::from(libc::AF_UNIX)), ]; + if mode == NetworkSeccompMode::ProxyRouted(ProxyRouting::TransparentTcp) { + // syscall_result supplies protocol zero (NETLINK_ROUTE). + allowed.push((libc::SYS_socket, i64::from(libc::AF_NETLINK))); + } let probes = allowed .into_iter() .flat_map(|(nr, arg0)| [(nr, arg0), (nr | 0x4000_0000, arg0)]) diff --git a/codex-rs/linux-sandbox/src/tcp_namespace.rs b/codex-rs/linux-sandbox/src/tcp_namespace.rs new file mode 100644 index 0000000000..ff158db589 --- /dev/null +++ b/codex-rs/linux-sandbox/src/tcp_namespace.rs @@ -0,0 +1,133 @@ +//! Create a private user and network namespace before invoking stock bubblewrap. +//! Only the trusted launcher configures the TUN and the resolver sockets. + +use rustix::thread::CapabilitySet; +use rustix::thread::CapabilitySets; +use rustix::thread::UnshareFlags; +use std::fs; +use std::fs::File; +use std::fs::OpenOptions; +use std::io; +use std::net::Ipv4Addr; +use std::net::Ipv6Addr; +use std::net::TcpListener; +use std::net::UdpSocket; +use std::os::fd::AsRawFd; +use std::os::fd::OwnedFd; +use std::os::fd::RawFd; +use std::os::unix::fs::OpenOptionsExt; + +// linux/ipv6_route.h. libc's in6_rtmsg fields are private. +#[repr(C)] +struct Ipv6Route { + dst: libc::in6_addr, + src: libc::in6_addr, + gateway: libc::in6_addr, + kind: u32, + dst_len: u16, + src_len: u16, + metric: u32, + info: libc::c_ulong, + flags: u32, + ifindex: libc::c_int, +} +const _: () = assert!(std::mem::size_of::() == std::mem::size_of::()); + +pub(super) fn create() -> io::Result<[File; 3]> { + let uid = rustix::process::geteuid().as_raw(); + let gid = rustix::process::getegid().as_raw(); + // SAFETY: this launcher is single-threaded and keeps its file descriptor table. + unsafe { rustix::thread::unshare_unsafe(UnshareFlags::NEWUSER | UnshareFlags::NEWNET)? }; + fs::write("/proc/self/uid_map", format!("{uid} {uid} 1\n"))?; + fs::write("/proc/self/setgroups", "deny")?; + fs::write("/proc/self/gid_map", format!("{gid} {gid} 1\n"))?; + let tun = OpenOptions::new() + .read(true) + .write(true) + .custom_flags(libc::O_NOFOLLOW) + .open("/dev/net/tun")?; + crate::proxy_routing::ensure_loopback_interface_up()?; + // SAFETY: these Linux ioctl records consist of integers, byte arrays and + // nullable pointers. Start with zeroed padding and set the active union + // member to the type required by each specific ioctl before invoking it. + unsafe { + let name = c"codextun"; + let mut iface: libc::ifreq = std::mem::zeroed(); + std::ptr::copy_nonoverlapping( + name.as_ptr(), + iface.ifr_name.as_mut_ptr(), + name.to_bytes_with_nul().len(), + ); + iface.ifr_ifru.ifru_flags = (libc::IFF_TUN | libc::IFF_NO_PI) as i16; + ioctl(tun.as_raw_fd(), libc::TUNSETIFF, &mut iface)?; + let v4 = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))?; + let v6 = UdpSocket::bind((Ipv6Addr::UNSPECIFIED, 0))?; + ioctl(v4.as_raw_fd(), libc::SIOCGIFFLAGS as _, &mut iface)?; + iface.ifr_ifru.ifru_flags |= libc::IFF_UP as i16; + ioctl(v4.as_raw_fd(), libc::SIOCSIFFLAGS as _, &mut iface)?; + let index = libc::if_nametoindex(name.as_ptr()) as i32; + if index == 0 { + return Err(io::Error::last_os_error()); + } + let mut addr: libc::sockaddr_in = std::mem::zeroed(); + addr.sin_family = libc::AF_INET as _; + addr.sin_addr.s_addr = u32::from_ne_bytes([192, 0, 2, 1]); + iface.ifr_ifru.ifru_addr = std::mem::transmute::(addr); + ioctl(v4.as_raw_fd(), libc::SIOCSIFADDR as _, &mut iface)?; + addr.sin_addr.s_addr = u32::MAX; + iface.ifr_ifru.ifru_netmask = + std::mem::transmute::(addr); + ioctl(v4.as_raw_fd(), libc::SIOCSIFNETMASK as _, &mut iface)?; + let mut addr6: libc::in6_ifreq = std::mem::zeroed(); + addr6.ifr6_addr.s6_addr = Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1).octets(); + addr6.ifr6_prefixlen = 128; + addr6.ifr6_ifindex = index; + ioctl(v6.as_raw_fd(), libc::SIOCSIFADDR as _, &mut addr6)?; + let mut route: libc::rtentry = std::mem::zeroed(); + route.rt_dst.sa_family = libc::AF_INET as _; + route.rt_genmask.sa_family = libc::AF_INET as _; + route.rt_gateway.sa_family = libc::AF_INET as _; + route.rt_flags = libc::RTF_UP; + route.rt_dev = iface.ifr_name.as_mut_ptr(); + ioctl(v4.as_raw_fd(), libc::SIOCADDRT as _, &mut route)?; + let mut route6: Ipv6Route = std::mem::zeroed(); + route6.ifindex = index; + route6.flags = u32::from(libc::RTF_UP); + route6.metric = 1; + ioctl(v6.as_raw_fd(), libc::SIOCADDRT as _, &mut route6)?; + } + let dns = (Ipv4Addr::new(127, 0, 0, 53), 53); + let udp = UdpSocket::bind(dns)?; + let tcp = TcpListener::bind(dns)?; + // Linux 5.12+ requires SETFCAP to map parent UID 0 into bwrap's nested + // namespace. The inner stage verifies that bwrap dropped this final capability. + let keep = if uid == 0 { + CapabilitySet::SETFCAP + } else { + CapabilitySet::empty() + }; + rustix::thread::set_no_new_privs(true)?; + rustix::thread::set_capabilities( + None, + CapabilitySets { + effective: keep, + permitted: keep, + inheritable: CapabilitySet::empty(), + }, + )?; + let [tun, udp, tcp] = [OwnedFd::from(tun), udp.into(), tcp.into()]; + Ok([ + File::from(crate::proxy_lifecycle::move_fd_above_stdio(tun)?), + File::from(crate::proxy_lifecycle::move_fd_above_stdio(udp)?), + File::from(crate::proxy_lifecycle::move_fd_above_stdio(tcp)?), + ]) +} + +// SAFETY: the caller pairs each request number with its exact Linux ABI record. +unsafe fn ioctl(fd: RawFd, request: libc::Ioctl, record: &mut T) -> io::Result<()> { + if unsafe { libc::ioctl(fd, request, record) } < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} diff --git a/codex-rs/linux-sandbox/src/tcp_net_setup.rs b/codex-rs/linux-sandbox/src/tcp_net_setup.rs new file mode 100644 index 0000000000..8009457645 --- /dev/null +++ b/codex-rs/linux-sandbox/src/tcp_net_setup.rs @@ -0,0 +1,134 @@ +//! Pass the private network descriptors and mount the command-local resolver. The inner stage +//! takes ownership of all three received descriptors before any workload exec. + +use crate::bwrap::BwrapArgs; +use crate::proxy_lifecycle::move_fd_above_stdio; +use codex_protocol::protocol::FileSystemSandboxPolicy; +use std::fs::File; +use std::io; +use std::io::Seek; +use std::io::Write; +use std::os::fd::AsRawFd; +use std::os::fd::FromRawFd; +use std::os::fd::OwnedFd; +use std::path::Path; + +pub(super) fn prepare( + files: [File; 3], + bwrap: &mut BwrapArgs, + policy: &FileSystemSandboxPolicy, + cwd: &Path, +) -> io::Result<()> { + let path = Path::new("/etc/resolv.conf"); + // Bind onto the existing target so ordinary systemd-resolved symlinks work. + // Reject policies masking either the logical name or the resolved location. + let target = path.canonicalize()?; + if !resolver_readable(policy, cwd, path, &target)? || !target.is_file() { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "transparent TCP requires readable /etc/resolv.conf", + )); + } + let fd = rustix::fs::memfd_create("codex-resolver", rustix::fs::MemfdFlags::CLOEXEC)?; + let mut resolv = File::from(move_fd_above_stdio(fd)?); + resolv.write_all(b"nameserver 127.0.0.53\n")?; + resolv.rewind()?; + let numbers = files + .each_ref() + .map(|f| f.as_raw_fd().to_string()) + .join(","); + let separator = bwrap + .args + .iter() + .position(|arg| arg == "--") + .ok_or_else(|| io::Error::other("missing inner command"))?; + bwrap.args.splice( + separator + 2..separator + 2, + [ + "--transparent-tcp".into(), + "--transparent-tcp-fd".into(), + numbers, + ], + ); + bwrap.args.splice( + separator..separator, + [ + "--perms".into(), + "0444".into(), + "--ro-bind-data".into(), + resolv.as_raw_fd().to_string(), + target + .to_str() + .ok_or_else(|| io::Error::other("non-UTF-8 resolver path"))? + .into(), + ], + ); + bwrap.preserved_files.push(resolv); + bwrap.preserved_files.extend(files); + Ok(()) +} + +fn resolver_readable( + policy: &FileSystemSandboxPolicy, + cwd: &Path, + logical: &Path, + target: &Path, +) -> io::Result { + let denied = codex_protocol::permissions::ReadDenyMatcher::try_new_for_local_paths(policy, cwd) + .map_err(io::Error::other)?; + Ok([logical, target].into_iter().all(|path| { + !denied + .as_ref() + .is_some_and(|matcher| matcher.is_local_path_read_denied(path)) + && (policy.can_read_local_path_with_cwd(path, cwd) + || (policy.include_platform_defaults() + && crate::bwrap::LINUX_PLATFORM_DEFAULT_READ_ROOTS + .iter() + .any(|root| path.starts_with(root)))) + })) +} + +pub(super) fn receive(fds: Vec) -> io::Result<[OwnedFd; 3]> { + let invalid = || io::Error::other("invalid transparent TCP descriptors"); + let fds: [i32; 3] = fds.try_into().map_err(|_| invalid())?; + for (i, &fd) in fds.iter().enumerate() { + if fd <= 2 || fds[..i].contains(&fd) { + return Err(invalid()); + } + if unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) } < 0 { + return Err(io::Error::last_os_error()); + } + } + // SAFETY: the launcher handed off three distinct, live descriptors above stdio. + Ok(fds.map(|fd| unsafe { OwnedFd::from_raw_fd(fd) })) +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_protocol::protocol::FileSystemAccessMode; + use codex_protocol::protocol::FileSystemPath; + use codex_protocol::protocol::FileSystemSandboxEntry; + use codex_protocol::protocol::FileSystemSpecialPath; + + #[test] + fn minimal_resolver_mount_respects_explicit_denials_and_symlink_targets() { + let mut policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry::new( + FileSystemPath::Special { + value: FileSystemSpecialPath::Minimal, + }, + FileSystemAccessMode::Read, + )]); + let path = Path::new("/etc/resolv.conf"); + let cwd = Path::new("/"); + assert!(resolver_readable(&policy, cwd, path, path).unwrap()); + assert!(!resolver_readable(&policy, cwd, path, Path::new("/private/resolv.conf")).unwrap()); + policy.entries.push(FileSystemSandboxEntry::new( + codex_utils_absolute_path::AbsolutePathBuf::try_from(path) + .unwrap() + .into(), + FileSystemAccessMode::Deny, + )); + assert!(!resolver_readable(&policy, cwd, path, path).unwrap()); + } +} diff --git a/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs b/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs index 16b13d9016..5e2c9b0c17 100644 --- a/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs +++ b/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs @@ -37,6 +37,8 @@ use tempfile::NamedTempFile; use tokio::process::Command; use url::Url; +#[path = "managed_tcp.rs"] +mod managed_tcp; #[path = "managed_proxy_unix_sockets_tests.rs"] mod unix_sockets; @@ -89,7 +91,10 @@ fn is_bwrap_unavailable_output(output: &Output) -> bool { } async fn should_skip_bwrap_tests() -> bool { - let mut env = create_env_from_core_vars(); + should_skip_bwrap_tests_with_env(create_env_from_core_vars()).await +} + +async fn should_skip_bwrap_tests_with_env(mut env: HashMap) -> bool { strip_proxy_env(&mut env); let output = run_linux_sandbox_direct( @@ -112,11 +117,14 @@ fn is_managed_proxy_permission_error(stderr: &str) -> bool { } async fn managed_proxy_skip_reason() -> Option { - if should_skip_bwrap_tests().await { + managed_proxy_skip_reason_with_env(create_env_from_core_vars()).await +} + +async fn managed_proxy_skip_reason_with_env(mut env: HashMap) -> Option { + if should_skip_bwrap_tests_with_env(env.clone()).await { return Some("bubblewrap is unavailable 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()); @@ -163,6 +171,22 @@ fn linux_sandbox_command( permission_profile: &PermissionProfile, allow_network_for_proxy: bool, env: HashMap, +) -> Command { + linux_sandbox_command_with_options( + command, + permission_profile, + allow_network_for_proxy.then_some("{}"), + env, + &[], + ) +} + +fn linux_sandbox_command_with_options( + command: &[&str], + permission_profile: &PermissionProfile, + managed_network: Option<&str>, + env: HashMap, + options: &[&str], ) -> Command { let cwd = std::env::current_dir().expect("current directory should exist"); let permission_profile_json = @@ -174,10 +198,11 @@ fn linux_sandbox_command( "--permission-profile".to_string(), permission_profile_json, ]; - if allow_network_for_proxy { + if let Some(context) = managed_network { args.push("--managed-network".to_string()); - args.push("{}".to_string()); + args.push(context.to_string()); } + args.extend(options.iter().map(|arg| (*arg).to_string())); args.push("--".to_string()); args.extend(command.iter().map(|entry| (*entry).to_string())); diff --git a/codex-rs/linux-sandbox/tests/suite/managed_tcp.rs b/codex-rs/linux-sandbox/tests/suite/managed_tcp.rs new file mode 100644 index 0000000000..7beae59d50 --- /dev/null +++ b/codex-rs/linux-sandbox/tests/suite/managed_tcp.rs @@ -0,0 +1,213 @@ +//! Exercise native name resolution and ordinary TCP through the two-stage Linux launcher. + +use super::*; +use codex_utils_cargo_bin::runfiles; +use pretty_assertions::assert_eq; +use socks5_protocol::CommandRequest; +use socks5_protocol::sync::FromIO; +use tokio::io::AsyncReadExt; +use tokio::io::AsyncWriteExt; + +#[tokio::test] +async fn transparent_tcp_rejects_incompatible_options_and_untrusted_proxy_ports() { + let incompatible = + "transparent TCP requires managed networking and private bubblewrap namespaces"; + let untrusted = "outside the managed sandbox context"; + let mut env = create_env_from_core_vars(); + strip_proxy_env(&mut env); + env.insert("ALL_PROXY".into(), "socks5h://127.0.0.1:32101".into()); + for (context, extra, reason) in [ + (None, None, incompatible), + (Some("{}"), Some("--use-legacy-landlock"), incompatible), + (Some("{}"), Some("--inherit-pid-namespace"), incompatible), + (Some("{}"), Some("--no-proc"), incompatible), + (Some("{}"), None, untrusted), + (Some(r#"{"loopbackPorts":[32102]}"#), None, untrusted), + ] { + let mut options = vec!["--transparent-tcp"]; + options.extend(extra); + let output = linux_sandbox_command_with_options( + &["true"], + &PermissionProfile::read_only(), + context, + env.clone(), + &options, + ) + .output() + .await + .unwrap(); + assert!(!output.status.success()); + assert!( + String::from_utf8_lossy(&output.stderr).contains(reason), + "{output:?}" + ); + } +} + +#[tokio::test] +async fn managed_tcp_native_client_uses_attributed_socks_and_preserves_restrictions() { + // Container device policies can expose the node but deny opening it. + if let Err(err) = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open("/dev/net/tun") + { + if matches!( + err.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::PermissionDenied + ) || err.raw_os_error() == Some(libc::ENODEV) + { + eprintln!("skipping transparent TCP integration test: /dev/net/tun: {err}"); + return; + } + panic!("failed to open /dev/net/tun: {err}"); + } + let mut env = create_env_from_core_vars(); + strip_proxy_env(&mut env); + // Keep distro bwrap private to this child. Other tests exercise bundled discovery. + // Stage outside the runfiles cwd, which the production launcher rejects for executables. + let _bwrap_dir = if cfg!(target_arch = "x86_64") && option_env!("BAZEL_PACKAGE").is_some() { + let bin = tempfile::tempdir().unwrap(); + let files = runfiles::Runfiles::create().expect("managed TCP test runfiles"); + let bwrap = runfiles::rlocation!( + files, + "managed_tcp_test_bubblewrap_noble_amd64/usr/bin/bwrap" + ) + .expect("Ubuntu bubblewrap test fixture"); + codex_utils_cargo_bin::copy_executable(&bwrap, &bin.path().join("bwrap")).unwrap(); + let path = env.get("PATH").map(String::as_str).unwrap_or_default(); + env.insert("PATH".into(), format!("{path}:{}", bin.path().display())); + Some(bin) + } else { + None + }; + if let Some(reason) = managed_proxy_skip_reason_with_env(env.clone()).await { + eprintln!("skipping managed TCP sandbox test: {reason}"); + return; + } + let proxy = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .await + .unwrap(); + let proxy_addr = proxy.local_addr().unwrap(); + let host_only = TcpListener::bind((Ipv4Addr::new(127, 0, 0, 2), 0)).unwrap(); + let host_port = host_only.local_addr().unwrap().port().to_string(); + let read_only = NamedTempFile::new_in(std::env::current_dir().unwrap()).unwrap(); + env.insert("ALL_PROXY".into(), format!("socks5h://{proxy_addr}")); + env.insert( + PROXY_ATTRIBUTION_TOKEN_ENV_KEY.into(), + "native-client".into(), + ); + let serve = |mut stdin: tokio::process::ChildStdin| async move { + let mut kept = None; + for (host, allow) in [ + ("pg.example:5432", true), + ("pg.example:5432", true), + ("[2001:db8::93]:5432", true), + ("v6.example:5432", true), + ("blocked.example:5432", false), + ] { + let (mut stream, _) = proxy.accept().await.unwrap(); + let mut preface = Vec::new(); + write_attribution_frame(&mut preface, "native-client").unwrap(); + preface.extend_from_slice(&[5, 1, 0]); + let mut buf = vec![0; preface.len()]; + stream.read_exact(&mut buf).await.unwrap(); + assert_eq!(buf, preface); + stream.write_all(&[5, 0]).await.unwrap(); + let mut expected = Vec::new(); + CommandRequest::connect(host.parse().unwrap()) + .write_to(&mut expected) + .unwrap(); + let mut request = vec![0; expected.len()]; + stream.read_exact(&mut request).await.unwrap(); + assert_eq!(request, expected); + if host == "pg.example:5432" { + let Some(first) = kept.take() else { + stdin.write_all(b"open\n").await.unwrap(); + kept = Some(stream); + continue; + }; + stdin.write_all(b"close\n").await.unwrap(); + assert_eq!(stream.read(&mut [0]).await.unwrap(), 0); + stream = first; + } + let status = if allow { 0 } else { 2 }; + stream + .write_all(&[5, status, 0, 1, 0, 0, 0, 0, 0, 0]) + .await + .unwrap(); + if allow { + let mut payload = [0; 4]; + stream.read_exact(&mut payload).await.unwrap(); + assert_eq!(&payload, b"ping"); + stream.write_all(b"pong\n").await.unwrap(); + } + } + }; + let script = r#" + unset ALL_PROXY all_proxy + if command -v ip >/dev/null; then + ip link show codextun >/dev/null + if ip link set codextun down 2>/dev/null; then exit 15; fi + fi + exec 3<>/dev/tcp/pg.example/5432 + printf ping >&3 + read -r open + exec 4<>/dev/tcp/pg.example/5432 + printf queued >&4 + read -r release + exec 4>&- + IFS= read -r reply <&3 && test "$reply" = pong || exit 11 + exec 3>&- + exec 3<>/dev/tcp/2001:db8::93/5432 + printf ping >&3 + IFS= read -r reply <&3 && test "$reply" = pong || exit 11 + exec 3>&- + # Force an AAAA lookup so IPv4 fallback cannot satisfy the hostname check. + python3 - <<'PY' +import socket +with socket.socket(socket.AF_INET6, socket.SOCK_STREAM) as client: + client.connect(("v6.example", 5432)) + client.sendall(b"ping") + assert client.makefile("rb").readline() == b"pong\n" +PY + if { exec 4<>/dev/tcp/blocked.example/5432; } 2>/dev/null; then + if IFS= read -r -t 2 reply <&4; then exit 12; fi + fi + if (exec 5<>/dev/tcp/127.0.0.2/"$1") 2>/dev/null; then exit 13; fi + if (exec 5>>"$2") 2>/dev/null; then exit 14; fi + "#; + let mut command = linux_sandbox_command_with_options( + &[ + "bash", + "-ec", + script, + "probe", + &host_port, + read_only.path().to_str().unwrap(), + ], + &PermissionProfile::read_only(), + Some(&format!(r#"{{"loopbackPorts":[{}]}}"#, proxy_addr.port())), + env, + &["--transparent-tcp"], + ); + let mut child = command + .stdin(std::process::Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let peer = tokio::spawn(serve(child.stdin.take().unwrap())); + let output = tokio::time::timeout(SANDBOX_TIMEOUT, child.wait_with_output()) + .await + .unwrap() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + tokio::time::timeout(OPERATION_TIMEOUT, peer) + .await + .unwrap() + .unwrap(); +} diff --git a/codex-rs/network-proxy/src/mitm_tests.rs b/codex-rs/network-proxy/src/mitm_tests.rs index d4fcd34ce9..e883733ee4 100644 --- a/codex-rs/network-proxy/src/mitm_tests.rs +++ b/codex-rs/network-proxy/src/mitm_tests.rs @@ -61,7 +61,10 @@ fn policy_ctx( #[tokio::test] async fn mitm_policy_blocks_disallowed_method_and_records_telemetry() { let app_state = Arc::new(network_proxy_state_for_policy({ - let mut network = NetworkProxyConfig::default(); + let mut network = NetworkProxyConfig { + allow_local_binding: Some(true), + ..NetworkProxyConfig::default() + }; network.set_allowed_domains(vec!["example.com".to_string()]); network })); @@ -211,6 +214,7 @@ async fn mitm_policy_allows_matching_hooked_write_in_full_mode() { mitm: true, mitm_hooks: vec![hook], mode: NetworkMode::Full, + allow_local_binding: Some(true), ..NetworkProxyConfig::default() }; network.set_allowed_domains(vec!["api.github.com".to_string()]); @@ -392,6 +396,7 @@ async fn mitm_policy_blocks_encoded_path_traversal_for_repository_allowlist() { mitm: true, mitm_hooks: vec![hook], mode: NetworkMode::Full, + allow_local_binding: Some(true), ..NetworkProxyConfig::default() }; network.set_allowed_domains(vec!["github.com".to_string()]); @@ -460,6 +465,7 @@ async fn mitm_policy_blocks_matching_hooked_write_in_limited_mode() { mitm: true, mitm_hooks: vec![hook], mode: NetworkMode::Limited, + allow_local_binding: Some(true), ..NetworkProxyConfig::default() }; network.set_allowed_domains(vec!["api.github.com".to_string()]); @@ -509,6 +515,7 @@ async fn mitm_policy_records_hook_denials_in_full_mode() { mitm: true, mitm_hooks: vec![hook], mode: NetworkMode::Full, + allow_local_binding: Some(true), ..NetworkProxyConfig::default() }; network.set_allowed_domains(vec!["api.github.com".to_string()]); diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index ed0e4996be..e65815aaa4 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -2528,6 +2528,14 @@ mod tests { #[tokio::test] async fn remote_launch_config_carries_execution_scope() -> Result<()> { + let dir = tempfile::tempdir()?; + let ca = crate::certs::ManagedMitmCa::for_test(dir.path())?; + crate::certs::TEST_MITM_CA + .scope(ca, check_remote_launch_config_execution_scope()) + .await + } + + async fn check_remote_launch_config_execution_scope() -> Result<()> { #[cfg(target_os = "windows")] let _permit = WINDOWS_INGRESS_TEST_LOCK.acquire().await.unwrap(); let mut config = NetworkProxyConfig { diff --git a/codex-rs/network-proxy/src/socks5.rs b/codex-rs/network-proxy/src/socks5.rs index 4a28777d5d..a5cc5e7df6 100644 --- a/codex-rs/network-proxy/src/socks5.rs +++ b/codex-rs/network-proxy/src/socks5.rs @@ -1008,6 +1008,7 @@ mod tests { enabled: true, mode: NetworkMode::Limited, mitm: true, + allow_local_binding: Some(true), ..NetworkProxyConfig::default() }; settings.set_allowed_domains(vec!["example.com".to_string()]); @@ -1076,6 +1077,7 @@ mod tests { mode: NetworkMode::Full, mitm: true, credential_broker: true, + allow_local_binding: Some(true), ..NetworkProxyConfig::default() }; settings.set_allowed_domains(vec!["api.openai.com".to_string()]); @@ -1104,6 +1106,7 @@ mod tests { let mut settings = NetworkProxyConfig { enabled: true, mode: NetworkMode::Limited, + allow_local_binding: Some(true), ..NetworkProxyConfig::default() }; settings.set_allowed_domains(vec!["example.com".to_string()]); @@ -1139,6 +1142,7 @@ mod tests { }, ..MitmHookConfig::default() }], + allow_local_binding: Some(true), ..NetworkProxyConfig::default() }; settings.set_allowed_domains(vec!["api.github.com".to_string()]); @@ -1171,6 +1175,7 @@ mod tests { }, ..MitmHookConfig::default() }], + allow_local_binding: Some(true), ..NetworkProxyConfig::default() }; settings.set_allowed_domains(vec!["api.github.com".to_string()]); diff --git a/codex-rs/sandboxing/src/manager.rs b/codex-rs/sandboxing/src/manager.rs index eb2454f4ff..98f13e53d7 100644 --- a/codex-rs/sandboxing/src/manager.rs +++ b/codex-rs/sandboxing/src/manager.rs @@ -280,6 +280,7 @@ impl std::error::Error for SandboxTransformError { #[derive(Clone, Default)] pub struct SandboxManager { linux_sandbox_pid_namespace: LinuxSandboxPidNamespace, + linux_sandbox_transparent_tcp: bool, operation: SandboxOperation, #[cfg(target_os = "macos")] allowed_symlinked_codex_home: Option, @@ -318,6 +319,12 @@ impl SandboxManager { self } + /// Route managed Linux command traffic through the transparent TCP adapter. + pub fn with_linux_sandbox_transparent_tcp(mut self, enabled: bool) -> Self { + self.linux_sandbox_transparent_tcp = enabled; + self + } + /// Allows otherwise-authorized writable roots beneath the opted-in user home /// to follow symlinks, including targets outside that home. #[cfg(target_os = "macos")] @@ -663,6 +670,9 @@ impl SandboxManager { if self.linux_sandbox_pid_namespace == LinuxSandboxPidNamespace::Inherit { args.insert(0, "--inherit-pid-namespace".to_owned()); } + if self.linux_sandbox_transparent_tcp && managed_network.is_some() { + args.insert(0, "--transparent-tcp".to_owned()); + } exec.command = std::iter::once(program).chain(args).collect(); // Without bwrap --argv0, the Linux helper uses its own argv[0] as the inner executable. let arg0 = if let Some(path) = exe.to_str() diff --git a/codex-rs/sandboxing/src/manager_tests.rs b/codex-rs/sandboxing/src/manager_tests.rs index b838f5c2ec..64dd5a4a7c 100644 --- a/codex-rs/sandboxing/src/manager_tests.rs +++ b/codex-rs/sandboxing/src/manager_tests.rs @@ -593,7 +593,15 @@ fn inherited_terminal_preserves_native_linux_helper_and_uses_its_dispatch_name() #[cfg(unix)] #[tokio::test] -async fn linux_unix_socket_grant_uses_effective_managed_policy() -> anyhow::Result<()> { +async fn linux_helper_uses_effective_managed_policy() -> anyhow::Result<()> { + for transparent_tcp in [false, true] { + check_linux_managed_policy(transparent_tcp).await?; + } + Ok(()) +} + +#[cfg(unix)] +async fn check_linux_managed_policy(transparent_tcp: bool) -> anyhow::Result<()> { use codex_network_proxy::ConfigReloader; use codex_network_proxy::ConfigReloaderFuture; use codex_network_proxy::ConfigState; @@ -647,7 +655,7 @@ async fn linux_unix_socket_grant_uses_effective_managed_policy() -> anyhow::Resu let denied = ManagedNetworkSandboxContext::default(); let cwd = AbsolutePathBuf::current_dir()?; let cwd_uri = PathUri::from_abs_path(&cwd); - let manager = SandboxManager::new(); + let manager = SandboxManager::new().with_linux_sandbox_transparent_tcp(transparent_tcp); for (label, context, live_proxy, enforce_managed_network, expected) in [ ( "missing policy stays managed with restrictive defaults", @@ -707,9 +715,13 @@ async fn linux_unix_socket_grant_uses_effective_managed_policy() -> anyhow::Resu use_legacy_landlock: false, windows_sandbox_level: WindowsSandboxLevel::Disabled, })?; - let transported_context = prepared - .into_command_request() - .command + let command = prepared.into_command_request().command; + assert_eq!( + command.iter().any(|arg| arg == "--transparent-tcp"), + transparent_tcp && expected.is_some(), + "{label}" + ); + let transported_context = command .windows(2) .find(|args| args[0] == "--managed-network") .map(|args| serde_json::from_str::(&args[1]))