diff --git a/.github/scripts/install-musl-build-tools.sh b/.github/scripts/install-musl-build-tools.sh new file mode 100644 index 0000000000..e2af7528d5 --- /dev/null +++ b/.github/scripts/install-musl-build-tools.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${TARGET:?TARGET environment variable is required}" +: "${GITHUB_ENV:?GITHUB_ENV environment variable is required}" + +apt_update_args=() +if [[ -n "${APT_UPDATE_ARGS:-}" ]]; then + # shellcheck disable=SC2206 + apt_update_args=(${APT_UPDATE_ARGS}) +fi + +apt_install_args=() +if [[ -n "${APT_INSTALL_ARGS:-}" ]]; then + # shellcheck disable=SC2206 + apt_install_args=(${APT_INSTALL_ARGS}) +fi + +sudo apt-get update "${apt_update_args[@]}" +sudo apt-get install -y "${apt_install_args[@]}" musl-tools pkg-config g++ clang libc++-dev libc++abi-dev lld + +case "${TARGET}" in + x86_64-unknown-linux-musl) + arch="x86_64" + ;; + aarch64-unknown-linux-musl) + arch="aarch64" + ;; + *) + echo "Unexpected musl target: ${TARGET}" >&2 + exit 1 + ;; +esac + +if command -v clang++ >/dev/null; then + cxx="$(command -v clang++)" + echo "CXXFLAGS=--target=${TARGET} -stdlib=libc++ -pthread" >> "$GITHUB_ENV" + echo "CFLAGS=--target=${TARGET} -pthread" >> "$GITHUB_ENV" + if command -v clang >/dev/null; then + cc="$(command -v clang)" + echo "CC=${cc}" >> "$GITHUB_ENV" + echo "TARGET_CC=${cc}" >> "$GITHUB_ENV" + target_cc_var="CC_${TARGET}" + target_cc_var="${target_cc_var//-/_}" + echo "${target_cc_var}=${cc}" >> "$GITHUB_ENV" + fi +elif command -v "${arch}-linux-musl-g++" >/dev/null; then + cxx="$(command -v "${arch}-linux-musl-g++")" +elif command -v musl-g++ >/dev/null; then + cxx="$(command -v musl-g++)" +elif command -v musl-gcc >/dev/null; then + cxx="$(command -v musl-gcc)" + echo "CFLAGS=-pthread" >> "$GITHUB_ENV" +else + echo "musl g++ not found after install; arch=${arch}" >&2 + exit 1 +fi + +echo "CXX=${cxx}" >> "$GITHUB_ENV" +echo "CMAKE_CXX_COMPILER=${cxx}" >> "$GITHUB_ENV" +echo "CMAKE_ARGS=-DCMAKE_HAVE_THREADS_LIBRARY=1 -DCMAKE_USE_PTHREADS_INIT=1 -DCMAKE_THREAD_LIBS_INIT=-pthread -DTHREADS_PREFER_PTHREAD_FLAG=ON" >> "$GITHUB_ENV" diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 22a1a8320e..a6bf51369b 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -265,11 +265,11 @@ jobs: name: Install musl build tools env: DEBIAN_FRONTEND: noninteractive + TARGET: ${{ matrix.target }} + APT_UPDATE_ARGS: -o Acquire::Retries=3 + APT_INSTALL_ARGS: --no-install-recommends shell: bash - run: | - set -euo pipefail - sudo apt-get -y update -o Acquire::Retries=3 - sudo apt-get -y install --no-install-recommends musl-tools pkg-config + run: bash "${GITHUB_WORKSPACE}/.github/scripts/install-musl-build-tools.sh" - name: Install cargo-chef if: ${{ matrix.profile == 'release' }} diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 1ab7f51755..9033d058d7 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -106,9 +106,9 @@ jobs: - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Install musl build tools - run: | - sudo apt-get update - sudo apt-get install -y musl-tools pkg-config + env: + TARGET: ${{ matrix.target }} + run: bash "${GITHUB_WORKSPACE}/.github/scripts/install-musl-build-tools.sh" - name: Cargo build shell: bash diff --git a/.github/workflows/shell-tool-mcp.yml b/.github/workflows/shell-tool-mcp.yml index 62a4989b77..0e03fea43c 100644 --- a/.github/workflows/shell-tool-mcp.yml +++ b/.github/workflows/shell-tool-mcp.yml @@ -99,9 +99,9 @@ jobs: - if: ${{ matrix.install_musl }} name: Install musl build dependencies - run: | - sudo apt-get update - sudo apt-get install -y musl-tools pkg-config + env: + TARGET: ${{ matrix.target }} + run: bash "${GITHUB_WORKSPACE}/.github/scripts/install-musl-build-tools.sh" - name: Build exec server binaries run: cargo build --release --target ${{ matrix.target }} --bin codex-exec-mcp-server --bin codex-execve-wrapper diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index c8fc0223f6..2f2da13454 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -193,6 +193,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "getrandom 0.3.3", "once_cell", "version_check", "zerocopy", @@ -261,7 +262,7 @@ version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67555e1f1ece39d737e28c8a017721287753af3f93225e4a445b29ccb0f5912c" dependencies = [ - "nom", + "nom 7.1.3", "ratatui", "simdutf8", "smallvec", @@ -591,6 +592,16 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "asynk-strim" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52697735bdaac441a29391a9e97102c74c6ef0f9b60a40cf109b1b404e29d2f6" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -618,7 +629,7 @@ dependencies = [ "hyper", "hyper-util", "itoa", - "matchit", + "matchit 0.8.4", "memchr", "mime", "percent-encoding", @@ -686,6 +697,24 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.10.0", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 2.0.104", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -744,6 +773,15 @@ dependencies = [ "piper", ] +[[package]] +name = "borsh" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" +dependencies = [ + "cfg_aliases 0.2.1", +] + [[package]] name = "bstr" version = "1.12.0" @@ -820,10 +858,11 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.30" +version = "1.2.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "deec109607ca693028562ed836a5f1c4b8bd77755c4e132fc5ce11b0b6211ae7" +checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" dependencies = [ + "find-msvc-tools", "jobserver", "libc", "shlex", @@ -835,6 +874,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "cfg-if" version = "1.0.1" @@ -894,6 +942,17 @@ dependencies = [ "inout", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" version = "4.5.54" @@ -953,6 +1012,15 @@ dependencies = [ "error-code", ] +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + [[package]] name = "cmp_any" version = "0.8.1" @@ -1599,6 +1667,35 @@ dependencies = [ "wiremock", ] +[[package]] +name = "codex-network-proxy" +version = "0.0.0" +dependencies = [ + "anyhow", + "async-trait", + "clap", + "codex-app-server-protocol", + "codex-core", + "codex-utils-absolute-path", + "globset", + "pretty_assertions", + "rama-core", + "rama-http", + "rama-http-backend", + "rama-net", + "rama-tcp", + "rama-tls-boring", + "rama-unix", + "serde", + "serde_json", + "tempfile", + "time", + "tokio", + "tracing", + "tracing-subscriber", + "url", +] + [[package]] name = "codex-ollama" version = "0.0.0" @@ -2009,6 +2106,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "const_format" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +dependencies = [ + "const_format_proc_macros", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + [[package]] name = "convert_case" version = "0.6.0" @@ -2096,6 +2213,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -2171,6 +2294,27 @@ dependencies = [ "typenum", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "ctor" version = "0.1.26" @@ -2663,6 +2807,24 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" +[[package]] +name = "endian-type" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "869b0adbda23651a9c5c0c3d270aac9fcb52e8622a8f2b17e57802d7791962f2" + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -2778,7 +2940,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" dependencies = [ "futures-core", - "nom", + "nom 7.1.3", "pin-project-lite", ] @@ -2809,6 +2971,9 @@ name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +dependencies = [ + "getrandom 0.2.16", +] [[package]] name = "fax" @@ -2861,6 +3026,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" + [[package]] name = "findshlibs" version = "0.10.2" @@ -2909,6 +3080,18 @@ dependencies = [ "num-traits", ] +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "fastrand", + "futures-core", + "futures-sink", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" @@ -2933,7 +3116,28 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" dependencies = [ - "foreign-types-shared", + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", ] [[package]] @@ -2942,6 +3146,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -2951,6 +3161,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -2960,6 +3176,16 @@ dependencies = [ "libc", ] +[[package]] +name = "fslock" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04412b8935272e3a9bae6f48c7bfff74c2911f60525404edfdd28e49884c3bfb" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "futures" version = "0.3.31" @@ -3071,6 +3297,21 @@ dependencies = [ "byteorder", ] +[[package]] +name = "generator" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link 0.2.0", + "windows-result 0.3.4", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -3133,6 +3374,12 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "globset" version = "0.4.16" @@ -3231,6 +3478,52 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand 0.9.2", + "ring", + "thiserror 2.0.17", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "moka", + "once_cell", + "parking_lot", + "rand 0.9.2", + "resolv-conf", + "smallvec", + "thiserror 2.0.17", + "tokio", + "tracing", +] + [[package]] name = "hkdf" version = "0.12.4" @@ -3314,6 +3607,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -3755,6 +4054,18 @@ dependencies = [ "rustversion", ] +[[package]] +name = "ipconfig" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" +dependencies = [ + "socket2 0.5.10", + "widestring", + "windows-sys 0.48.0", + "winreg 0.50.0", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -4000,6 +4311,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.0", +] + [[package]] name = "libredox" version = "0.1.6" @@ -4083,6 +4404,22 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "pin-utils", + "scoped-tls", + "serde", + "serde_json", + "tracing", + "tracing-subscriber", +] + [[package]] name = "lru" version = "0.12.5" @@ -4141,6 +4478,12 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "matchit" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3eede3bdf92f3b4f9dc04072a9ce5ab557d5ec9038773bf9ffcd5588b3cc05b" + [[package]] name = "mcp-types" version = "0.0.0" @@ -4170,6 +4513,12 @@ dependencies = [ "wiremock", ] +[[package]] +name = "md5" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" + [[package]] name = "memchr" version = "2.7.5" @@ -4238,6 +4587,23 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "moka" +version = "0.12.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3dec6bd31b08944e08b58fd99373893a6c17054d6f3ea5006cc894f4f4eee2a" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + [[package]] name = "moxcms" version = "0.7.5" @@ -4342,6 +4708,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -4596,6 +4971,10 @@ name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -4611,7 +4990,7 @@ checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" dependencies = [ "bitflags 2.10.0", "cfg-if", - "foreign-types", + "foreign-types 0.3.2", "libc", "once_cell", "openssl-macros", @@ -5025,7 +5404,7 @@ dependencies = [ "shared_library", "shell-words", "winapi", - "winreg", + "winreg 0.10.1", ] [[package]] @@ -5170,6 +5549,21 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "psl" +version = "2.1.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a54161bdf033552d905f5e653fb6263690e63df54745ad5199a5f8fa802a0d6" +dependencies = [ + "psl-types", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + [[package]] name = "pulldown-cmark" version = "0.10.3" @@ -5298,10 +5692,340 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" dependencies = [ - "endian-type", + "endian-type 0.1.2", "nibble_vec", ] +[[package]] +name = "radix_trie" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b4431027dcd37fc2a73ef740b5f233aa805897935b8bce0195e41bbf9a3289a" +dependencies = [ + "endian-type 0.2.0", + "nibble_vec", +] + +[[package]] +name = "rama-boring" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "288926585d0b8ed1b1dd278a31ea1367007ad0bd4263ca84810e10939c2398a3" +dependencies = [ + "bitflags 2.10.0", + "foreign-types 0.5.0", + "libc", + "openssl-macros", + "rama-boring-sys", +] + +[[package]] +name = "rama-boring-sys" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "421ebb40444a6d740f867a5055a710a73e7cd74b9b389583b45e9b29d1330465" +dependencies = [ + "bindgen", + "cmake", + "fs_extra", + "fslock", +] + +[[package]] +name = "rama-boring-tokio" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913cf3d377b37ff903cd57c2f6133ba7da5b7e0fe94821dec83daa8a024bb6ce" +dependencies = [ + "rama-boring", + "rama-boring-sys", + "tokio", +] + +[[package]] +name = "rama-core" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b93751ab27c9d151e84c1100057eab3f2a6a1378bc31b62abd416ecb1847658" +dependencies = [ + "ahash", + "asynk-strim", + "bytes", + "futures", + "parking_lot", + "pin-project-lite", + "rama-error", + "rama-macros", + "rama-utils", + "serde", + "serde_json", + "tokio", + "tokio-graceful", + "tokio-util", + "tracing", +] + +[[package]] +name = "rama-dns" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e340fef2799277e204260b17af01bc23604712092eacd6defe40167f304baed8" +dependencies = [ + "ahash", + "hickory-resolver", + "rama-core", + "rama-net", + "rama-utils", + "serde", + "tokio", +] + +[[package]] +name = "rama-error" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c452aba1beb7e29b873ff32f304536164cffcc596e786921aea64e858ff8f40" + +[[package]] +name = "rama-http" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "453d60af031e23af2d48995e41b17023f6150044738680508b63671f8d7417dd" +dependencies = [ + "ahash", + "base64", + "bitflags 2.10.0", + "chrono", + "const_format", + "csv", + "http 1.3.1", + "http-range-header", + "httpdate", + "iri-string", + "matchit 0.9.1", + "parking_lot", + "percent-encoding", + "pin-project-lite", + "radix_trie 0.3.0", + "rama-core", + "rama-error", + "rama-http-headers", + "rama-http-types", + "rama-net", + "rama-utils", + "rand 0.9.2", + "serde", + "serde_html_form", + "serde_json", + "tokio", + "uuid", +] + +[[package]] +name = "rama-http-backend" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ff6a3c8ae690be8167e43777ba0bf6b0c8c2f6de165c538666affe2a32fd81" +dependencies = [ + "h2", + "pin-project-lite", + "rama-core", + "rama-http", + "rama-http-core", + "rama-http-headers", + "rama-http-types", + "rama-net", + "rama-tcp", + "rama-unix", + "rama-utils", + "tokio", +] + +[[package]] +name = "rama-http-core" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3822be6703e010afec0bcfeb5dbb6e5a3b23ca5689d9b1215b66ce6446653b77" +dependencies = [ + "ahash", + "atomic-waker", + "futures-channel", + "httparse", + "httpdate", + "indexmap 2.12.0", + "itoa", + "parking_lot", + "pin-project-lite", + "rama-core", + "rama-http", + "rama-http-types", + "rama-utils", + "slab", + "tokio", + "tokio-test", + "want", +] + +[[package]] +name = "rama-http-headers" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d74fe0cd9bd4440827dc6dc0f504cf66065396532e798891dee2c1b740b2285" +dependencies = [ + "ahash", + "base64", + "chrono", + "const_format", + "httpdate", + "rama-core", + "rama-error", + "rama-http-types", + "rama-macros", + "rama-net", + "rama-utils", + "rand 0.9.2", + "serde", + "sha1", +] + +[[package]] +name = "rama-http-types" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6dae655a72da5f2b97cfacb67960d8b28c5025e62707b4c8c5f0c5c9843a444" +dependencies = [ + "ahash", + "bytes", + "const_format", + "fnv", + "http 1.3.1", + "http-body", + "http-body-util", + "itoa", + "memchr", + "mime", + "mime_guess", + "nom 8.0.0", + "pin-project-lite", + "rama-core", + "rama-error", + "rama-macros", + "rama-utils", + "rand 0.9.2", + "serde", + "serde_json", + "sync_wrapper", + "tokio", +] + +[[package]] +name = "rama-macros" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea18a110bcf21e35c5f194168e6914ccea45ffdd0fea51bc4b169fbeafef6428" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "rama-net" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b28ee9e1e5d39264414b71f5c33e7fbb66b382c3fac456fe0daad39cf5509933" +dependencies = [ + "ahash", + "const_format", + "flume", + "hex", + "ipnet", + "itertools 0.14.0", + "md5", + "nom 8.0.0", + "parking_lot", + "pin-project-lite", + "psl", + "radix_trie 0.3.0", + "rama-core", + "rama-http-types", + "rama-macros", + "rama-utils", + "serde", + "sha2", + "socket2 0.6.1", + "tokio", +] + +[[package]] +name = "rama-tcp" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe60cd604f91196b3659a1b28945add2e8b10bd0b4e6373c93d024fb3197704b" +dependencies = [ + "pin-project-lite", + "rama-core", + "rama-dns", + "rama-http-types", + "rama-net", + "rama-utils", + "rand 0.9.2", + "tokio", +] + +[[package]] +name = "rama-tls-boring" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "def3d5d06d3ca3a2d2e4376cf93de0555cd9c7960f085bf77be9562f5c9ace8f" +dependencies = [ + "ahash", + "flume", + "itertools 0.14.0", + "moka", + "parking_lot", + "pin-project-lite", + "rama-boring", + "rama-boring-tokio", + "rama-core", + "rama-http-types", + "rama-net", + "rama-utils", + "schannel", + "tokio", +] + +[[package]] +name = "rama-unix" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91acb16d571428ba4cece072dfab90d2667cdfa910a7b3cb4530c3f31542d708" +dependencies = [ + "pin-project-lite", + "rama-core", + "rama-net", + "tokio", +] + +[[package]] +name = "rama-utils" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf28b18ba4a57f8334d7992d3f8020194ea359b246ae6f8f98b8df524c7a14ef" +dependencies = [ + "const_format", + "parking_lot", + "pin-project-lite", + "rama-macros", + "regex", + "serde", + "smallvec", + "smol_str", + "tokio", + "wildcard", +] + [[package]] name = "rand" version = "0.8.5" @@ -5540,6 +6264,12 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + [[package]] name = "ring" version = "0.17.14" @@ -5718,7 +6448,7 @@ dependencies = [ "log", "memchr", "nix 0.28.0", - "radix_trie", + "radix_trie 0.2.1", "unicode-segmentation", "unicode-width 0.1.14", "utf8parse", @@ -5862,6 +6592,12 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -6106,6 +6842,19 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "serde_html_form" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acf96b1d9364968fce46ebb548f1c0e1d7eceae27bdff73865d42e6c7369d94" +dependencies = [ + "form_urlencoded", + "indexmap 2.12.0", + "itoa", + "ryu", + "serde_core", +] + [[package]] name = "serde_json" version = "1.0.145" @@ -6367,6 +7116,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "smawk" @@ -6374,6 +7126,16 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" +[[package]] +name = "smol_str" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f7a918bd2a9951d18ee6e48f076843e8e73a9a5d22cf05bcd4b7a81bdd04e17" +dependencies = [ + "borsh", + "serde_core", +] + [[package]] name = "socket2" version = "0.5.10" @@ -6394,6 +7156,15 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + [[package]] name = "sse-stream" version = "0.2.1" @@ -6676,6 +7447,12 @@ dependencies = [ "libc", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tempfile" version = "3.23.0" @@ -6959,6 +7736,19 @@ dependencies = [ "windows-sys 0.61.1", ] +[[package]] +name = "tokio-graceful" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45740b38b48641855471cd402922e89156bdfbd97b69b45eeff170369cc18c7d" +dependencies = [ + "loom", + "pin-project-lite", + "slab", + "tokio", + "tracing", +] + [[package]] name = "tokio-macros" version = "2.6.0" @@ -7379,7 +8169,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f943391d896cdfe8eec03a04d7110332d445be7df856db382dd96a730667562c" dependencies = [ "memchr", - "nom", + "nom 7.1.3", "once_cell", "petgraph", ] @@ -7914,6 +8704,21 @@ dependencies = [ "winsafe", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "wildcard" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9b0540e91e49de3817c314da0dd3bc518093ceacc6ea5327cb0e1eb073e5189" +dependencies = [ + "thiserror 2.0.17", +] + [[package]] name = "wildmatch" version = "2.6.1" @@ -8143,6 +8948,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -8448,6 +9262,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "winres" version = "0.1.12" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index caadeecd64..9a58bcbfac 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -27,6 +27,7 @@ members = [ "login", "mcp-server", "mcp-types", + "network-proxy", "ollama", "process-hardening", "protocol", @@ -136,6 +137,7 @@ env-flags = "0.1.1" env_logger = "0.11.5" eventsource-stream = "0.2.3" futures = { version = "0.3", default-features = false } +globset = "0.4" http = "1.3.1" icu_decimal = "2.1" icu_locale_core = "2.1" diff --git a/codex-rs/deny.toml b/codex-rs/deny.toml index 0a4a08bd89..3e260ac24e 100644 --- a/codex-rs/deny.toml +++ b/codex-rs/deny.toml @@ -73,6 +73,7 @@ ignore = [ { id = "RUSTSEC-2024-0388", reason = "derivative is unmaintained; pulled in via starlark v0.13.0 used by execpolicy/cli/core; no fixed release yet" }, { id = "RUSTSEC-2025-0057", reason = "fxhash is unmaintained; pulled in via starlark_map/starlark v0.13.0 used by execpolicy/cli/core; no fixed release yet" }, { id = "RUSTSEC-2024-0436", reason = "paste is unmaintained; pulled in via ratatui/rmcp/starlark used by tui/execpolicy; no fixed release yet" }, + { id = "RUSTSEC-2025-0134", reason = "rustls-pemfile is unmaintained; pulled in via rama-tls-rustls used by codex-network-proxy; no safe upgrade until rama removes the dependency" }, # TODO(joshka, nornagon): remove this exception when once we update the ratatui fork to a version that uses lru 0.13+. { id = "RUSTSEC-2026-0002", reason = "lru 0.12.5 is pulled in via ratatui fork; cannot upgrade until the fork is updated" }, ] diff --git a/codex-rs/network-proxy/Cargo.toml b/codex-rs/network-proxy/Cargo.toml new file mode 100644 index 0000000000..2d303b1c95 --- /dev/null +++ b/codex-rs/network-proxy/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "codex-network-proxy" +edition = "2024" +version = { workspace = true } +license.workspace = true + +[[bin]] +name = "codex-network-proxy" +path = "src/main.rs" + +[lib] +name = "codex_network_proxy" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +async-trait = { workspace = true } +clap = { workspace = true, features = ["derive"] } +codex-app-server-protocol = { workspace = true } +codex-core = { workspace = true } +codex-utils-absolute-path = { workspace = true } +globset = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +time = { workspace = true } +tokio = { workspace = true, features = ["full"] } +tracing = { workspace = true } +tracing-subscriber = { workspace = true, features = ["fmt"] } +url = { workspace = true } +rama-core = { version = "=0.3.0-alpha.4" } +rama-http = { version = "=0.3.0-alpha.4" } +rama-http-backend = { version = "=0.3.0-alpha.4", features = ["tls"] } +rama-net = { version = "=0.3.0-alpha.4", features = ["http", "tls"] } +rama-tcp = { version = "=0.3.0-alpha.4", features = ["http"] } +rama-tls-boring = { version = "=0.3.0-alpha.4", features = ["http"] } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } + +[target.'cfg(target_family = "unix")'.dependencies] +rama-unix = { version = "=0.3.0-alpha.4" } diff --git a/codex-rs/network-proxy/README.md b/codex-rs/network-proxy/README.md new file mode 100644 index 0000000000..1d19a92a68 --- /dev/null +++ b/codex-rs/network-proxy/README.md @@ -0,0 +1,169 @@ +# codex-network-proxy + +`codex-network-proxy` is Codex's local network policy enforcement proxy. It runs: + +- an HTTP proxy (default `127.0.0.1:3128`) +- an admin HTTP API (default `127.0.0.1:8080`) + +It enforces an allow/deny policy and a "limited" mode intended for read-only network access. + +## Quickstart + +### 1) Configure + +`codex-network-proxy` reads from Codex's merged `config.toml` (via `codex-core` config loading). + +Example config: + +```toml +[network_proxy] +enabled = true +proxy_url = "http://127.0.0.1:3128" +admin_url = "http://127.0.0.1:8080" +# When `enabled` is false, the proxy no-ops and does not bind listeners. +# When true, respect HTTP(S)_PROXY/ALL_PROXY for upstream requests (HTTP(S) proxies only), +# including CONNECT tunnels in full mode. +allow_upstream_proxy = false +# By default, non-loopback binds are clamped to loopback for safety. +# If you want to expose these listeners beyond localhost, you must opt in explicitly. +dangerously_allow_non_loopback_proxy = false +dangerously_allow_non_loopback_admin = false +mode = "limited" # or "full" + +[network_proxy.policy] +# Hosts must match the allowlist (unless denied). +# If `allowed_domains` is empty, the proxy blocks requests until an allowlist is configured. +allowed_domains = ["*.openai.com"] +denied_domains = ["evil.example"] + +# If false, local/private networking is rejected. Explicit allowlisting of local IP literals +# (or `localhost`) is required to permit them. +# Hostnames that resolve to local/private IPs are still blocked even if allowlisted. +allow_local_binding = false + +# macOS-only: allows proxying to a unix socket when request includes `x-unix-socket: /path`. +allow_unix_sockets = ["/tmp/example.sock"] +``` + +### 2) Run the proxy + +```bash +cargo run -p codex-network-proxy -- +``` + +### 3) Point a client at it + +For HTTP(S) traffic: + +```bash +export HTTP_PROXY="http://127.0.0.1:3128" +export HTTPS_PROXY="http://127.0.0.1:3128" +``` + +### 4) Understand blocks / debugging + +When a request is blocked, the proxy responds with `403` and includes: + +- `x-proxy-error`: one of: + - `blocked-by-allowlist` + - `blocked-by-denylist` + - `blocked-by-method-policy` + - `blocked-by-policy` + +In "limited" mode, only `GET`, `HEAD`, and `OPTIONS` are allowed for plain HTTP. HTTPS `CONNECT` +remains a transparent tunnel, so limited-mode method enforcement does not apply to HTTPS. + +## Library API + +`codex-network-proxy` can be embedded as a library with a thin API: + +```rust +use codex_network_proxy::{NetworkProxy, NetworkDecision, NetworkPolicyRequest}; + +let proxy = NetworkProxy::builder() + .http_addr("127.0.0.1:8080".parse()?) + .admin_addr("127.0.0.1:9000".parse()?) + .policy_decider(|request: NetworkPolicyRequest| async move { + // Example: auto-allow when exec policy already approved a command prefix. + if let Some(command) = request.command.as_deref() { + if command.starts_with("curl ") { + return NetworkDecision::Allow; + } + } + NetworkDecision::Deny { + reason: "policy_denied".to_string(), + } + }) + .build() + .await?; + +let handle = proxy.run().await?; +handle.shutdown().await?; +``` + +When unix socket proxying is enabled, HTTP/admin bind overrides are still clamped to loopback +to avoid turning the proxy into a remote bridge to local daemons. + +### Policy hook (exec-policy mapping) + +The proxy exposes a policy hook (`NetworkPolicyDecider`) that can override allowlist-only blocks. +It receives `command` and `exec_policy_hint` fields when supplied by the embedding app. This lets +core map exec approvals to network access, e.g. if a user already approved `curl *` for a session, +the decider can auto-allow network requests originating from that command. + +**Important:** Explicit deny rules still win. The decider only gets a chance to override +`not_allowed` (allowlist misses), not `denied` or `not_allowed_local`. + +## Admin API + +The admin API is a small HTTP server intended for debugging and runtime adjustments. + +Endpoints: + +```bash +curl -sS http://127.0.0.1:8080/health +curl -sS http://127.0.0.1:8080/config +curl -sS http://127.0.0.1:8080/patterns +curl -sS http://127.0.0.1:8080/blocked + +# Switch modes without restarting: +curl -sS -X POST http://127.0.0.1:8080/mode -d '{"mode":"full"}' + +# Force a config reload: +curl -sS -X POST http://127.0.0.1:8080/reload +``` + +## Platform notes + +- Unix socket proxying via the `x-unix-socket` header is **macOS-only**; other platforms will + reject unix socket requests. +- HTTPS tunneling uses BoringSSL via Rama's `rama-tls-boring`; building the proxy requires a + native toolchain and CMake on macOS/Linux/Windows. + +## Security notes (important) + +This section documents the protections implemented by `codex-network-proxy`, and the boundaries of +what it can reasonably guarantee. + +- Allowlist-first policy: if `allowed_domains` is empty, requests are blocked until an allowlist is configured. +- Deny wins: entries in `denied_domains` always override the allowlist. +- Local/private network protection: when `allow_local_binding = false`, the proxy blocks loopback + and common private/link-local ranges. Explicit allowlisting of local IP literals (or `localhost`) + is required to permit them; hostnames that resolve to local/private IPs are still blocked even if + allowlisted (best-effort DNS lookup). +- Limited mode enforcement: + - only `GET`, `HEAD`, and `OPTIONS` are allowed + - HTTPS `CONNECT` remains a tunnel; limited-mode method enforcement does not apply to HTTPS +- Listener safety defaults: + - the admin API is unauthenticated; non-loopback binds are clamped unless explicitly enabled via + `dangerously_allow_non_loopback_admin` +- the HTTP proxy listener similarly clamps non-loopback binds unless explicitly enabled via + `dangerously_allow_non_loopback_proxy` +- when unix socket proxying is enabled, both listeners are forced to loopback to avoid turning the + proxy into a remote bridge into local daemons. +- `enabled` is enforced at runtime; when false the proxy no-ops and does not bind listeners. +Limitations: + +- DNS rebinding is hard to fully prevent without pinning the resolved IP(s) all the way down to the + transport layer. If your threat model includes hostile DNS, enforce network egress at a lower + layer too (e.g., firewall / VPC / corporate proxy policies). diff --git a/codex-rs/network-proxy/src/admin.rs b/codex-rs/network-proxy/src/admin.rs new file mode 100644 index 0000000000..3c883603a9 --- /dev/null +++ b/codex-rs/network-proxy/src/admin.rs @@ -0,0 +1,160 @@ +use crate::config::NetworkMode; +use crate::responses::json_response; +use crate::responses::text_response; +use crate::state::NetworkProxyState; +use anyhow::Context; +use anyhow::Result; +use rama_core::rt::Executor; +use rama_core::service::service_fn; +use rama_http::Body; +use rama_http::Request; +use rama_http::Response; +use rama_http::StatusCode; +use rama_http_backend::server::HttpServer; +use rama_tcp::server::TcpListener; +use serde::Deserialize; +use serde::Serialize; +use std::convert::Infallible; +use std::net::SocketAddr; +use std::sync::Arc; +use tracing::error; +use tracing::info; + +pub async fn run_admin_api(state: Arc, addr: SocketAddr) -> Result<()> { + // Debug-only admin API (health/config/patterns/blocked + mode/reload). Policy is config-driven + // and constraint-enforced; this endpoint should not become a second policy/approval plane. + let listener = TcpListener::build() + .bind(addr) + .await + // See `http_proxy.rs` for details on why we wrap `BoxError` before converting to anyhow. + .map_err(rama_core::error::OpaqueError::from) + .map_err(anyhow::Error::from) + .with_context(|| format!("bind admin API: {addr}"))?; + + let server_state = state.clone(); + let server = HttpServer::auto(Executor::new()).service(service_fn(move |req| { + let state = server_state.clone(); + async move { handle_admin_request(state, req).await } + })); + info!("admin API listening on {addr}"); + listener.serve(server).await; + Ok(()) +} + +async fn handle_admin_request( + state: Arc, + req: Request, +) -> Result { + const MODE_BODY_LIMIT: usize = 8 * 1024; + + let method = req.method().clone(); + let path = req.uri().path().to_string(); + let response = match (method.as_str(), path.as_str()) { + ("GET", "/health") => Response::new(Body::from("ok")), + ("GET", "/config") => match state.current_cfg().await { + Ok(cfg) => json_response(&cfg), + Err(err) => { + error!("failed to load config: {err}"); + text_response(StatusCode::INTERNAL_SERVER_ERROR, "error") + } + }, + ("GET", "/patterns") => match state.current_patterns().await { + Ok((allow, deny)) => json_response(&PatternsResponse { + allowed: allow, + denied: deny, + }), + Err(err) => { + error!("failed to load patterns: {err}"); + text_response(StatusCode::INTERNAL_SERVER_ERROR, "error") + } + }, + ("GET", "/blocked") => match state.drain_blocked().await { + Ok(blocked) => json_response(&BlockedResponse { blocked }), + Err(err) => { + error!("failed to read blocked queue: {err}"); + text_response(StatusCode::INTERNAL_SERVER_ERROR, "error") + } + }, + ("POST", "/mode") => { + let mut body = req.into_body(); + let mut buf: Vec = Vec::new(); + loop { + let chunk = match body.chunk().await { + Ok(chunk) => chunk, + Err(err) => { + error!("failed to read mode body: {err}"); + return Ok(text_response(StatusCode::BAD_REQUEST, "invalid body")); + } + }; + let Some(chunk) = chunk else { + break; + }; + + if buf.len().saturating_add(chunk.len()) > MODE_BODY_LIMIT { + return Ok(text_response( + StatusCode::PAYLOAD_TOO_LARGE, + "body too large", + )); + } + buf.extend_from_slice(&chunk); + } + + if buf.is_empty() { + return Ok(text_response(StatusCode::BAD_REQUEST, "missing body")); + } + let update: ModeUpdate = match serde_json::from_slice(&buf) { + Ok(update) => update, + Err(err) => { + error!("failed to parse mode update: {err}"); + return Ok(text_response(StatusCode::BAD_REQUEST, "invalid json")); + } + }; + match state.set_network_mode(update.mode).await { + Ok(()) => json_response(&ModeUpdateResponse { + status: "ok", + mode: update.mode, + }), + Err(err) => { + error!("mode update failed: {err}"); + text_response(StatusCode::INTERNAL_SERVER_ERROR, "mode update failed") + } + } + } + ("POST", "/reload") => match state.force_reload().await { + Ok(()) => json_response(&ReloadResponse { status: "reloaded" }), + Err(err) => { + error!("reload failed: {err}"); + text_response(StatusCode::INTERNAL_SERVER_ERROR, "reload failed") + } + }, + _ => text_response(StatusCode::NOT_FOUND, "not found"), + }; + Ok(response) +} + +#[derive(Deserialize)] +struct ModeUpdate { + mode: NetworkMode, +} + +#[derive(Debug, Serialize)] +struct PatternsResponse { + allowed: Vec, + denied: Vec, +} + +#[derive(Debug, Serialize)] +struct BlockedResponse { + blocked: T, +} + +#[derive(Debug, Serialize)] +struct ModeUpdateResponse { + status: &'static str, + mode: NetworkMode, +} + +#[derive(Debug, Serialize)] +struct ReloadResponse { + status: &'static str, +} diff --git a/codex-rs/network-proxy/src/config.rs b/codex-rs/network-proxy/src/config.rs new file mode 100644 index 0000000000..7bd6957ac7 --- /dev/null +++ b/codex-rs/network-proxy/src/config.rs @@ -0,0 +1,433 @@ +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; +use serde::Deserialize; +use serde::Serialize; +use std::net::IpAddr; +use std::net::SocketAddr; +use tracing::warn; +use url::Url; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct NetworkProxyConfig { + #[serde(default)] + pub network_proxy: NetworkProxySettings, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NetworkProxySettings { + #[serde(default)] + pub enabled: bool, + #[serde(default = "default_proxy_url")] + pub proxy_url: String, + #[serde(default = "default_admin_url")] + pub admin_url: String, + #[serde(default)] + pub allow_upstream_proxy: bool, + #[serde(default)] + pub dangerously_allow_non_loopback_proxy: bool, + #[serde(default)] + pub dangerously_allow_non_loopback_admin: bool, + #[serde(default)] + pub mode: NetworkMode, + #[serde(default)] + pub policy: NetworkPolicy, +} + +impl Default for NetworkProxySettings { + fn default() -> Self { + Self { + enabled: false, + proxy_url: default_proxy_url(), + admin_url: default_admin_url(), + allow_upstream_proxy: false, + dangerously_allow_non_loopback_proxy: false, + dangerously_allow_non_loopback_admin: false, + mode: NetworkMode::default(), + policy: NetworkPolicy::default(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct NetworkPolicy { + #[serde(default)] + pub allowed_domains: Vec, + #[serde(default)] + pub denied_domains: Vec, + #[serde(default)] + pub allow_unix_sockets: Vec, + #[serde(default)] + pub allow_local_binding: bool, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum NetworkMode { + /// Limited (read-only) access: only GET/HEAD/OPTIONS are allowed for HTTP. HTTPS CONNECT is + /// blocked unless MITM is enabled so the proxy can enforce method policy on inner requests. + Limited, + /// Full network access: all HTTP methods are allowed, and HTTPS CONNECTs are tunneled without + /// MITM interception. + #[default] + Full, +} + +impl NetworkMode { + pub fn allows_method(self, method: &str) -> bool { + match self { + Self::Full => true, + Self::Limited => matches!(method, "GET" | "HEAD" | "OPTIONS"), + } + } +} + +fn default_proxy_url() -> String { + "http://127.0.0.1:3128".to_string() +} + +fn default_admin_url() -> String { + "http://127.0.0.1:8080".to_string() +} + +/// Clamp non-loopback bind addresses to loopback unless explicitly allowed. +fn clamp_non_loopback(addr: SocketAddr, allow_non_loopback: bool, name: &str) -> SocketAddr { + if addr.ip().is_loopback() { + return addr; + } + + if allow_non_loopback { + warn!("DANGEROUS: {name} listening on non-loopback address {addr}"); + return addr; + } + + warn!( + "{name} requested non-loopback bind ({addr}); clamping to 127.0.0.1:{port} (set dangerously_allow_non_loopback_proxy or dangerously_allow_non_loopback_admin to override)", + port = addr.port() + ); + SocketAddr::from(([127, 0, 0, 1], addr.port())) +} + +pub(crate) fn clamp_bind_addrs( + http_addr: SocketAddr, + admin_addr: SocketAddr, + cfg: &NetworkProxySettings, +) -> (SocketAddr, SocketAddr) { + let http_addr = clamp_non_loopback( + http_addr, + cfg.dangerously_allow_non_loopback_proxy, + "HTTP proxy", + ); + let admin_addr = clamp_non_loopback( + admin_addr, + cfg.dangerously_allow_non_loopback_admin, + "admin API", + ); + if cfg.policy.allow_unix_sockets.is_empty() { + return (http_addr, admin_addr); + } + + // `x-unix-socket` is intentionally a local escape hatch. If the proxy (or admin API) is + // reachable from outside the machine, it can become a remote bridge into local daemons + // (e.g. docker.sock). To avoid footguns, enforce loopback binding whenever unix sockets + // are enabled. + if cfg.dangerously_allow_non_loopback_proxy && !http_addr.ip().is_loopback() { + warn!( + "unix socket proxying is enabled; ignoring dangerously_allow_non_loopback_proxy and clamping HTTP proxy to loopback" + ); + } + if cfg.dangerously_allow_non_loopback_admin && !admin_addr.ip().is_loopback() { + warn!( + "unix socket proxying is enabled; ignoring dangerously_allow_non_loopback_admin and clamping admin API to loopback" + ); + } + ( + SocketAddr::from(([127, 0, 0, 1], http_addr.port())), + SocketAddr::from(([127, 0, 0, 1], admin_addr.port())), + ) +} + +pub struct RuntimeConfig { + pub http_addr: SocketAddr, + pub admin_addr: SocketAddr, +} + +pub fn resolve_runtime(cfg: &NetworkProxyConfig) -> Result { + let http_addr = resolve_addr(&cfg.network_proxy.proxy_url, 3128).with_context(|| { + format!( + "invalid network_proxy.proxy_url: {}", + cfg.network_proxy.proxy_url + ) + })?; + let admin_addr = resolve_addr(&cfg.network_proxy.admin_url, 8080).with_context(|| { + format!( + "invalid network_proxy.admin_url: {}", + cfg.network_proxy.admin_url + ) + })?; + let (http_addr, admin_addr) = clamp_bind_addrs(http_addr, admin_addr, &cfg.network_proxy); + + Ok(RuntimeConfig { + http_addr, + admin_addr, + }) +} + +fn resolve_addr(url: &str, default_port: u16) -> Result { + let addr_parts = parse_host_port(url, default_port)?; + let host = if addr_parts.host.eq_ignore_ascii_case("localhost") { + "127.0.0.1".to_string() + } else { + addr_parts.host + }; + match host.parse::() { + Ok(ip) => Ok(SocketAddr::new(ip, addr_parts.port)), + Err(_) => Ok(SocketAddr::from(([127, 0, 0, 1], addr_parts.port))), + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SocketAddressParts { + host: String, + port: u16, +} + +fn parse_host_port(url: &str, default_port: u16) -> Result { + let trimmed = url.trim(); + if trimmed.is_empty() { + bail!("missing host in network proxy address: {url}"); + } + + // Avoid treating unbracketed IPv6 literals like "2001:db8::1" as scheme-prefixed URLs. + if matches!(trimmed.parse::(), Ok(IpAddr::V6(_))) && !trimmed.starts_with('[') { + return Ok(SocketAddressParts { + host: trimmed.to_string(), + port: default_port, + }); + } + + // Prefer the standard URL parser when the input is URL-like. Prefix a scheme when absent so + // we still accept loose host:port inputs. + let candidate = if trimmed.contains("://") { + trimmed.to_string() + } else { + format!("http://{trimmed}") + }; + if let Ok(parsed) = Url::parse(&candidate) + && let Some(host) = parsed.host_str() + { + let host = host.trim_matches(|c| c == '[' || c == ']'); + if host.is_empty() { + bail!("missing host in network proxy address: {url}"); + } + return Ok(SocketAddressParts { + host: host.to_string(), + port: parsed.port().unwrap_or(default_port), + }); + } + + parse_host_port_fallback(trimmed, default_port) +} + +fn parse_host_port_fallback(input: &str, default_port: u16) -> Result { + let without_scheme = input + .split_once("://") + .map(|(_, rest)| rest) + .unwrap_or(input); + let host_port = without_scheme.split('/').next().unwrap_or(without_scheme); + let host_port = host_port + .rsplit_once('@') + .map(|(_, rest)| rest) + .unwrap_or(host_port); + + if host_port.starts_with('[') + && let Some(end) = host_port.find(']') + { + let host = &host_port[1..end]; + let port = host_port[end + 1..] + .strip_prefix(':') + .and_then(|port| port.parse::().ok()) + .unwrap_or(default_port); + if host.is_empty() { + bail!("missing host in network proxy address: {input}"); + } + return Ok(SocketAddressParts { + host: host.to_string(), + port, + }); + } + + // Only treat `host:port` as such when there's a single `:`. This avoids + // accidentally interpreting unbracketed IPv6 addresses as `host:port`. + if host_port.bytes().filter(|b| *b == b':').count() == 1 + && let Some((host, port)) = host_port.rsplit_once(':') + && let Ok(port) = port.parse::() + { + if host.is_empty() { + bail!("missing host in network proxy address: {input}"); + } + return Ok(SocketAddressParts { + host: host.to_string(), + port, + }); + } + + if host_port.is_empty() { + bail!("missing host in network proxy address: {input}"); + } + Ok(SocketAddressParts { + host: host_port.to_string(), + port: default_port, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + use pretty_assertions::assert_eq; + + #[test] + fn parse_host_port_defaults_for_empty_string() { + assert!(parse_host_port("", 1234).is_err()); + } + + #[test] + fn parse_host_port_defaults_for_whitespace() { + assert!(parse_host_port(" ", 5555).is_err()); + } + + #[test] + fn parse_host_port_parses_host_port_without_scheme() { + assert_eq!( + parse_host_port("127.0.0.1:8080", 3128).unwrap(), + SocketAddressParts { + host: "127.0.0.1".to_string(), + port: 8080, + } + ); + } + + #[test] + fn parse_host_port_parses_host_port_with_scheme_and_path() { + assert_eq!( + parse_host_port("http://example.com:8080/some/path", 3128).unwrap(), + SocketAddressParts { + host: "example.com".to_string(), + port: 8080, + } + ); + } + + #[test] + fn parse_host_port_strips_userinfo() { + assert_eq!( + parse_host_port("http://user:pass@host.example:5555", 3128).unwrap(), + SocketAddressParts { + host: "host.example".to_string(), + port: 5555, + } + ); + } + + #[test] + fn parse_host_port_parses_ipv6_with_brackets() { + assert_eq!( + parse_host_port("http://[::1]:9999", 3128).unwrap(), + SocketAddressParts { + host: "::1".to_string(), + port: 9999, + } + ); + } + + #[test] + fn parse_host_port_does_not_treat_unbracketed_ipv6_as_host_port() { + assert_eq!( + parse_host_port("2001:db8::1", 3128).unwrap(), + SocketAddressParts { + host: "2001:db8::1".to_string(), + port: 3128, + } + ); + } + + #[test] + fn parse_host_port_falls_back_to_default_port_when_port_is_invalid() { + assert_eq!( + parse_host_port("example.com:notaport", 3128).unwrap(), + SocketAddressParts { + host: "example.com:notaport".to_string(), + port: 3128, + } + ); + } + + #[test] + fn resolve_addr_maps_localhost_to_loopback() { + assert_eq!( + resolve_addr("localhost", 3128).unwrap(), + "127.0.0.1:3128".parse::().unwrap() + ); + } + + #[test] + fn resolve_addr_parses_ip_literals() { + assert_eq!( + resolve_addr("1.2.3.4", 80).unwrap(), + "1.2.3.4:80".parse::().unwrap() + ); + } + + #[test] + fn resolve_addr_parses_ipv6_literals() { + assert_eq!( + resolve_addr("http://[::1]:8080", 3128).unwrap(), + "[::1]:8080".parse::().unwrap() + ); + } + + #[test] + fn resolve_addr_falls_back_to_loopback_for_hostnames() { + assert_eq!( + resolve_addr("http://example.com:5555", 3128).unwrap(), + "127.0.0.1:5555".parse::().unwrap() + ); + } + + #[test] + fn clamp_bind_addrs_allows_non_loopback_when_enabled() { + let cfg = NetworkProxySettings { + dangerously_allow_non_loopback_proxy: true, + dangerously_allow_non_loopback_admin: true, + ..Default::default() + }; + let http_addr = "0.0.0.0:3128".parse::().unwrap(); + let admin_addr = "0.0.0.0:8080".parse::().unwrap(); + + let (http_addr, admin_addr) = clamp_bind_addrs(http_addr, admin_addr, &cfg); + + assert_eq!(http_addr, "0.0.0.0:3128".parse::().unwrap()); + assert_eq!(admin_addr, "0.0.0.0:8080".parse::().unwrap()); + } + + #[test] + fn clamp_bind_addrs_forces_loopback_when_unix_sockets_enabled() { + let cfg = NetworkProxySettings { + dangerously_allow_non_loopback_proxy: true, + dangerously_allow_non_loopback_admin: true, + policy: NetworkPolicy { + allow_unix_sockets: vec!["/tmp/docker.sock".to_string()], + ..Default::default() + }, + ..Default::default() + }; + let http_addr = "0.0.0.0:3128".parse::().unwrap(); + let admin_addr = "0.0.0.0:8080".parse::().unwrap(); + + let (http_addr, admin_addr) = clamp_bind_addrs(http_addr, admin_addr, &cfg); + + assert_eq!(http_addr, "127.0.0.1:3128".parse::().unwrap()); + assert_eq!(admin_addr, "127.0.0.1:8080".parse::().unwrap()); + } +} diff --git a/codex-rs/network-proxy/src/http_proxy.rs b/codex-rs/network-proxy/src/http_proxy.rs new file mode 100644 index 0000000000..abe6b30144 --- /dev/null +++ b/codex-rs/network-proxy/src/http_proxy.rs @@ -0,0 +1,636 @@ +use crate::config::NetworkMode; +use crate::network_policy::NetworkDecision; +use crate::network_policy::NetworkPolicyDecider; +use crate::network_policy::NetworkPolicyRequest; +use crate::network_policy::NetworkProtocol; +use crate::network_policy::evaluate_host_policy; +use crate::policy::normalize_host; +use crate::reasons::REASON_METHOD_NOT_ALLOWED; +use crate::reasons::REASON_NOT_ALLOWED; +use crate::reasons::REASON_PROXY_DISABLED; +use crate::responses::blocked_header_value; +use crate::responses::json_response; +use crate::runtime::unix_socket_permissions_supported; +use crate::state::BlockedRequest; +use crate::state::NetworkProxyState; +use crate::upstream::UpstreamClient; +use crate::upstream::proxy_for_connect; +use anyhow::Context as _; +use anyhow::Result; +use rama_core::Layer; +use rama_core::Service; +use rama_core::error::BoxError; +use rama_core::error::ErrorExt as _; +use rama_core::error::OpaqueError; +use rama_core::extensions::ExtensionsMut; +use rama_core::extensions::ExtensionsRef; +use rama_core::layer::AddInputExtensionLayer; +use rama_core::rt::Executor; +use rama_core::service::service_fn; +use rama_http::Body; +use rama_http::HeaderValue; +use rama_http::Request; +use rama_http::Response; +use rama_http::StatusCode; +use rama_http::layer::remove_header::RemoveRequestHeaderLayer; +use rama_http::layer::remove_header::RemoveResponseHeaderLayer; +use rama_http::matcher::MethodMatcher; +use rama_http_backend::client::proxy::layer::HttpProxyConnector; +use rama_http_backend::server::HttpServer; +use rama_http_backend::server::layer::upgrade::UpgradeLayer; +use rama_http_backend::server::layer::upgrade::Upgraded; +use rama_net::Protocol; +use rama_net::address::ProxyAddress; +use rama_net::client::ConnectorService; +use rama_net::client::EstablishedClientConnection; +use rama_net::http::RequestContext; +use rama_net::proxy::ProxyRequest; +use rama_net::proxy::ProxyTarget; +use rama_net::proxy::StreamForwardService; +use rama_net::stream::SocketInfo; +use rama_tcp::client::Request as TcpRequest; +use rama_tcp::client::service::TcpConnector; +use rama_tcp::server::TcpListener; +use rama_tls_boring::client::TlsConnectorDataBuilder; +use rama_tls_boring::client::TlsConnectorLayer; +use serde::Serialize; +use std::convert::Infallible; +use std::net::SocketAddr; +use std::sync::Arc; +use tracing::error; +use tracing::info; +use tracing::warn; + +pub async fn run_http_proxy( + state: Arc, + addr: SocketAddr, + policy_decider: Option>, +) -> Result<()> { + let listener = TcpListener::build() + .bind(addr) + .await + // Rama's `BoxError` is a `Box` without an explicit `'static` + // lifetime bound, which means it doesn't satisfy `anyhow::Context`'s `StdError` constraint. + // Wrap it in Rama's `OpaqueError` so we can preserve the original error as a source and + // still use `anyhow` for chaining. + .map_err(rama_core::error::OpaqueError::from) + .map_err(anyhow::Error::from) + .with_context(|| format!("bind HTTP proxy: {addr}"))?; + + let http_service = HttpServer::auto(Executor::new()).service( + ( + UpgradeLayer::new( + MethodMatcher::CONNECT, + service_fn({ + let policy_decider = policy_decider.clone(); + move |req| http_connect_accept(policy_decider.clone(), req) + }), + service_fn(http_connect_proxy), + ), + RemoveResponseHeaderLayer::hop_by_hop(), + RemoveRequestHeaderLayer::hop_by_hop(), + ) + .into_layer(service_fn({ + let policy_decider = policy_decider.clone(); + move |req| http_plain_proxy(policy_decider.clone(), req) + })), + ); + + info!("HTTP proxy listening on {addr}"); + + listener + .serve(AddInputExtensionLayer::new(state).into_layer(http_service)) + .await; + Ok(()) +} + +async fn http_connect_accept( + policy_decider: Option>, + mut req: Request, +) -> Result<(Response, Request), Response> { + let app_state = req + .extensions() + .get::>() + .cloned() + .ok_or_else(|| text_response(StatusCode::INTERNAL_SERVER_ERROR, "missing state"))?; + + let authority = match RequestContext::try_from(&req).map(|ctx| ctx.host_with_port()) { + Ok(authority) => authority, + Err(err) => { + warn!("CONNECT missing authority: {err}"); + return Err(text_response(StatusCode::BAD_REQUEST, "missing authority")); + } + }; + + let host = normalize_host(&authority.host.to_string()); + if host.is_empty() { + return Err(text_response(StatusCode::BAD_REQUEST, "invalid host")); + } + + let client = client_addr(&req); + + let enabled = app_state + .enabled() + .await + .map_err(|err| internal_error("failed to read enabled state", err))?; + if !enabled { + let client = client.as_deref().unwrap_or_default(); + warn!("CONNECT blocked; proxy disabled (client={client}, host={host})"); + return Err(proxy_disabled_response( + &app_state, + host, + client_addr(&req), + Some("CONNECT".to_string()), + "http-connect", + ) + .await); + } + + let request = NetworkPolicyRequest::new( + NetworkProtocol::HttpsConnect, + host.clone(), + authority.port, + client.clone(), + Some("CONNECT".to_string()), + None, + None, + ); + + match evaluate_host_policy(&app_state, policy_decider.as_ref(), &request).await { + Ok(NetworkDecision::Deny { reason }) => { + let _ = app_state + .record_blocked(BlockedRequest::new( + host.clone(), + reason.clone(), + client.clone(), + Some("CONNECT".to_string()), + None, + "http-connect".to_string(), + )) + .await; + let client = client.as_deref().unwrap_or_default(); + warn!("CONNECT blocked (client={client}, host={host}, reason={reason})"); + return Err(blocked_text(&reason)); + } + Ok(NetworkDecision::Allow) => { + let client = client.as_deref().unwrap_or_default(); + info!("CONNECT allowed (client={client}, host={host})"); + } + Err(err) => { + error!("failed to evaluate host for CONNECT {host}: {err}"); + return Err(text_response(StatusCode::INTERNAL_SERVER_ERROR, "error")); + } + } + + let mode = app_state + .network_mode() + .await + .map_err(|err| internal_error("failed to read network mode", err))?; + + if mode == NetworkMode::Limited { + let _ = app_state + .record_blocked(BlockedRequest::new( + host.clone(), + REASON_METHOD_NOT_ALLOWED.to_string(), + client.clone(), + Some("CONNECT".to_string()), + Some(NetworkMode::Limited), + "http-connect".to_string(), + )) + .await; + let client = client.as_deref().unwrap_or_default(); + warn!("CONNECT blocked by method policy (client={client}, host={host}, mode=limited)"); + return Err(blocked_text(REASON_METHOD_NOT_ALLOWED)); + } + + req.extensions_mut().insert(ProxyTarget(authority)); + req.extensions_mut().insert(mode); + + Ok(( + Response::builder() + .status(StatusCode::OK) + .body(Body::empty()) + .unwrap_or_else(|_| Response::new(Body::empty())), + req, + )) +} + +async fn http_connect_proxy(upgraded: Upgraded) -> Result<(), Infallible> { + if upgraded.extensions().get::().is_none() { + warn!("CONNECT missing proxy target"); + return Ok(()); + } + + let allow_upstream_proxy = match upgraded + .extensions() + .get::>() + .cloned() + { + Some(state) => match state.allow_upstream_proxy().await { + Ok(allowed) => allowed, + Err(err) => { + error!("failed to read upstream proxy setting: {err}"); + false + } + }, + None => { + error!("missing app state"); + false + } + }; + + let proxy = if allow_upstream_proxy { + proxy_for_connect() + } else { + None + }; + + if let Err(err) = forward_connect_tunnel(upgraded, proxy).await { + warn!("tunnel error: {err}"); + } + Ok(()) +} + +async fn forward_connect_tunnel( + upgraded: Upgraded, + proxy: Option, +) -> Result<(), BoxError> { + let authority = upgraded + .extensions() + .get::() + .map(|target| target.0.clone()) + .ok_or_else(|| OpaqueError::from_display("missing forward authority").into_boxed())?; + + let mut extensions = upgraded.extensions().clone(); + if let Some(proxy) = proxy { + extensions.insert(proxy); + } + + let req = TcpRequest::new_with_extensions(authority.clone(), extensions) + .with_protocol(Protocol::HTTPS); + let proxy_connector = HttpProxyConnector::optional(TcpConnector::new()); + let tls_config = TlsConnectorDataBuilder::new_http_auto().into_shared_builder(); + let connector = TlsConnectorLayer::tunnel(None) + .with_connector_data(tls_config) + .into_layer(proxy_connector); + let EstablishedClientConnection { conn: target, .. } = + connector.connect(req).await.map_err(|err| { + OpaqueError::from_boxed(err) + .with_context(|| format!("establish CONNECT tunnel to {authority}")) + .into_boxed() + })?; + + let proxy_req = ProxyRequest { + source: upgraded, + target, + }; + StreamForwardService::default() + .serve(proxy_req) + .await + .map_err(|err| { + OpaqueError::from_boxed(err.into()) + .with_context(|| format!("forward CONNECT tunnel to {authority}")) + .into_boxed() + }) +} + +async fn http_plain_proxy( + policy_decider: Option>, + req: Request, +) -> Result { + let app_state = match req.extensions().get::>().cloned() { + Some(state) => state, + None => { + error!("missing app state"); + return Ok(text_response(StatusCode::INTERNAL_SERVER_ERROR, "error")); + } + }; + let client = client_addr(&req); + + let method_allowed = match app_state + .method_allowed(req.method().as_str()) + .await + .map_err(|err| internal_error("failed to evaluate method policy", err)) + { + Ok(allowed) => allowed, + Err(resp) => return Ok(resp), + }; + + // `x-unix-socket` is an escape hatch for talking to local daemons. We keep it tightly scoped: + // macOS-only + explicit allowlist, to avoid turning the proxy into a general local capability + // escalation mechanism. + if let Some(unix_socket_header) = req.headers().get("x-unix-socket") { + let socket_path = match unix_socket_header.to_str() { + Ok(value) => value.to_string(), + Err(_) => { + warn!("invalid x-unix-socket header value (non-UTF8)"); + return Ok(text_response( + StatusCode::BAD_REQUEST, + "invalid x-unix-socket header", + )); + } + }; + let enabled = match app_state + .enabled() + .await + .map_err(|err| internal_error("failed to read enabled state", err)) + { + Ok(enabled) => enabled, + Err(resp) => return Ok(resp), + }; + if !enabled { + let client = client.as_deref().unwrap_or_default(); + warn!("unix socket blocked; proxy disabled (client={client}, path={socket_path})"); + return Ok(proxy_disabled_response( + &app_state, + socket_path, + client_addr(&req), + Some(req.method().as_str().to_string()), + "unix-socket", + ) + .await); + } + if !method_allowed { + let client = client.as_deref().unwrap_or_default(); + let method = req.method(); + warn!( + "unix socket blocked by method policy (client={client}, method={method}, mode=limited, allowed_methods=GET, HEAD, OPTIONS)" + ); + return Ok(json_blocked("unix-socket", REASON_METHOD_NOT_ALLOWED)); + } + + if !unix_socket_permissions_supported() { + warn!("unix socket proxy unsupported on this platform (path={socket_path})"); + return Ok(text_response( + StatusCode::NOT_IMPLEMENTED, + "unix sockets unsupported", + )); + } + + return match app_state.is_unix_socket_allowed(&socket_path).await { + Ok(true) => { + let client = client.as_deref().unwrap_or_default(); + info!("unix socket allowed (client={client}, path={socket_path})"); + match proxy_via_unix_socket(req, &socket_path).await { + Ok(resp) => Ok(resp), + Err(err) => { + warn!("unix socket proxy failed: {err}"); + Ok(text_response( + StatusCode::BAD_GATEWAY, + "unix socket proxy failed", + )) + } + } + } + Ok(false) => { + let client = client.as_deref().unwrap_or_default(); + warn!("unix socket blocked (client={client}, path={socket_path})"); + Ok(json_blocked("unix-socket", REASON_NOT_ALLOWED)) + } + Err(err) => { + warn!("unix socket check failed: {err}"); + Ok(text_response(StatusCode::INTERNAL_SERVER_ERROR, "error")) + } + }; + } + + let authority = match RequestContext::try_from(&req).map(|ctx| ctx.host_with_port()) { + Ok(authority) => authority, + Err(err) => { + warn!("missing host: {err}"); + return Ok(text_response(StatusCode::BAD_REQUEST, "missing host")); + } + }; + let host = normalize_host(&authority.host.to_string()); + let port = authority.port; + let enabled = match app_state + .enabled() + .await + .map_err(|err| internal_error("failed to read enabled state", err)) + { + Ok(enabled) => enabled, + Err(resp) => return Ok(resp), + }; + if !enabled { + let client = client.as_deref().unwrap_or_default(); + let method = req.method(); + warn!("request blocked; proxy disabled (client={client}, host={host}, method={method})"); + return Ok(proxy_disabled_response( + &app_state, + host, + client_addr(&req), + Some(req.method().as_str().to_string()), + "http", + ) + .await); + } + + let request = NetworkPolicyRequest::new( + NetworkProtocol::Http, + host.clone(), + port, + client.clone(), + Some(req.method().as_str().to_string()), + None, + None, + ); + + match evaluate_host_policy(&app_state, policy_decider.as_ref(), &request).await { + Ok(NetworkDecision::Deny { reason }) => { + let _ = app_state + .record_blocked(BlockedRequest::new( + host.clone(), + reason.clone(), + client.clone(), + Some(req.method().as_str().to_string()), + None, + "http".to_string(), + )) + .await; + let client = client.as_deref().unwrap_or_default(); + warn!("request blocked (client={client}, host={host}, reason={reason})"); + return Ok(json_blocked(&host, &reason)); + } + Ok(NetworkDecision::Allow) => {} + Err(err) => { + error!("failed to evaluate host for {host}: {err}"); + return Ok(text_response(StatusCode::INTERNAL_SERVER_ERROR, "error")); + } + } + + if !method_allowed { + let _ = app_state + .record_blocked(BlockedRequest::new( + host.clone(), + REASON_METHOD_NOT_ALLOWED.to_string(), + client.clone(), + Some(req.method().as_str().to_string()), + Some(NetworkMode::Limited), + "http".to_string(), + )) + .await; + let client = client.as_deref().unwrap_or_default(); + let method = req.method(); + warn!( + "request blocked by method policy (client={client}, host={host}, method={method}, mode=limited, allowed_methods=GET, HEAD, OPTIONS)" + ); + return Ok(json_blocked(&host, REASON_METHOD_NOT_ALLOWED)); + } + + let client = client.as_deref().unwrap_or_default(); + let method = req.method(); + info!("request allowed (client={client}, host={host}, method={method})"); + + let allow_upstream_proxy = match app_state + .allow_upstream_proxy() + .await + .map_err(|err| internal_error("failed to read upstream proxy config", err)) + { + Ok(allow) => allow, + Err(resp) => return Ok(resp), + }; + let client = if allow_upstream_proxy { + UpstreamClient::from_env_proxy() + } else { + UpstreamClient::direct() + }; + + match client.serve(req).await { + Ok(resp) => Ok(resp), + Err(err) => { + warn!("upstream request failed: {err}"); + Ok(text_response(StatusCode::BAD_GATEWAY, "upstream failure")) + } + } +} + +async fn proxy_via_unix_socket(req: Request, socket_path: &str) -> Result { + #[cfg(target_os = "macos")] + { + let client = UpstreamClient::unix_socket(socket_path); + + let (mut parts, body) = req.into_parts(); + let path = parts + .uri + .path_and_query() + .map(rama_http::uri::PathAndQuery::as_str) + .unwrap_or("/"); + parts.uri = path + .parse() + .with_context(|| format!("invalid unix socket request path: {path}"))?; + parts.headers.remove("x-unix-socket"); + + let req = Request::from_parts(parts, body); + client.serve(req).await.map_err(anyhow::Error::from) + } + #[cfg(not(target_os = "macos"))] + { + let _ = req; + let _ = socket_path; + Err(anyhow::anyhow!("unix sockets not supported")) + } +} + +fn client_addr(input: &T) -> Option { + input + .extensions() + .get::() + .map(|info| info.peer_addr().to_string()) +} + +fn json_blocked(host: &str, reason: &str) -> Response { + let response = BlockedResponse { + status: "blocked", + host, + reason, + }; + let mut resp = json_response(&response); + *resp.status_mut() = StatusCode::FORBIDDEN; + resp.headers_mut().insert( + "x-proxy-error", + HeaderValue::from_static(blocked_header_value(reason)), + ); + resp +} + +fn blocked_text(reason: &str) -> Response { + crate::responses::blocked_text_response(reason) +} + +async fn proxy_disabled_response( + app_state: &NetworkProxyState, + host: String, + client: Option, + method: Option, + protocol: &str, +) -> Response { + let _ = app_state + .record_blocked(BlockedRequest::new( + host, + REASON_PROXY_DISABLED.to_string(), + client, + method, + None, + protocol.to_string(), + )) + .await; + text_response(StatusCode::SERVICE_UNAVAILABLE, "proxy disabled") +} + +fn internal_error(context: &str, err: impl std::fmt::Display) -> Response { + error!("{context}: {err}"); + text_response(StatusCode::INTERNAL_SERVER_ERROR, "error") +} + +fn text_response(status: StatusCode, body: &str) -> Response { + Response::builder() + .status(status) + .header("content-type", "text/plain") + .body(Body::from(body.to_string())) + .unwrap_or_else(|_| Response::new(Body::from(body.to_string()))) +} + +#[derive(Serialize)] +struct BlockedResponse<'a> { + status: &'static str, + host: &'a str, + reason: &'a str, +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::config::NetworkMode; + use crate::config::NetworkPolicy; + use crate::runtime::network_proxy_state_for_policy; + use pretty_assertions::assert_eq; + use rama_http::Method; + use rama_http::Request; + use std::sync::Arc; + + #[tokio::test] + async fn http_connect_accept_blocks_in_limited_mode() { + let policy = NetworkPolicy { + allowed_domains: vec!["example.com".to_string()], + ..Default::default() + }; + let state = Arc::new(network_proxy_state_for_policy(policy)); + state.set_network_mode(NetworkMode::Limited).await.unwrap(); + + let mut req = Request::builder() + .method(Method::CONNECT) + .uri("https://example.com:443") + .header("host", "example.com:443") + .body(Body::empty()) + .unwrap(); + req.extensions_mut().insert(state); + + let response = http_connect_accept(None, req).await.unwrap_err(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!( + response.headers().get("x-proxy-error").unwrap(), + "blocked-by-method-policy" + ); + } +} diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs new file mode 100644 index 0000000000..71e4ec7356 --- /dev/null +++ b/codex-rs/network-proxy/src/lib.rs @@ -0,0 +1,29 @@ +#![deny(clippy::print_stdout, clippy::print_stderr)] + +mod admin; +mod config; +mod http_proxy; +mod network_policy; +mod policy; +mod proxy; +mod reasons; +mod responses; +mod runtime; +mod state; +mod upstream; + +use anyhow::Result; +pub use network_policy::NetworkDecision; +pub use network_policy::NetworkPolicyDecider; +pub use network_policy::NetworkPolicyRequest; +pub use network_policy::NetworkProtocol; +pub use proxy::Args; +pub use proxy::NetworkProxy; +pub use proxy::NetworkProxyBuilder; +pub use proxy::NetworkProxyHandle; + +pub async fn run_main(args: Args) -> Result<()> { + let _ = args; + let proxy = NetworkProxy::builder().build().await?; + proxy.run().await?.wait().await +} diff --git a/codex-rs/network-proxy/src/main.rs b/codex-rs/network-proxy/src/main.rs new file mode 100644 index 0000000000..7cb28aad57 --- /dev/null +++ b/codex-rs/network-proxy/src/main.rs @@ -0,0 +1,14 @@ +use anyhow::Result; +use clap::Parser; +use codex_network_proxy::Args; +use codex_network_proxy::NetworkProxy; + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt::init(); + + let args = Args::parse(); + let _ = args; + let proxy = NetworkProxy::builder().build().await?; + proxy.run().await?.wait().await +} diff --git a/codex-rs/network-proxy/src/network_policy.rs b/codex-rs/network-proxy/src/network_policy.rs new file mode 100644 index 0000000000..6f410b3e4b --- /dev/null +++ b/codex-rs/network-proxy/src/network_policy.rs @@ -0,0 +1,234 @@ +use crate::reasons::REASON_POLICY_DENIED; +use crate::runtime::HostBlockDecision; +use crate::runtime::HostBlockReason; +use crate::state::NetworkProxyState; +use anyhow::Result; +use async_trait::async_trait; +use std::future::Future; +use std::sync::Arc; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NetworkProtocol { + Http, + HttpsConnect, + Socks5Tcp, + Socks5Udp, +} + +#[derive(Clone, Debug)] +pub struct NetworkPolicyRequest { + pub protocol: NetworkProtocol, + pub host: String, + pub port: u16, + pub client_addr: Option, + pub method: Option, + pub command: Option, + pub exec_policy_hint: Option, +} + +impl NetworkPolicyRequest { + pub fn new( + protocol: NetworkProtocol, + host: String, + port: u16, + client_addr: Option, + method: Option, + command: Option, + exec_policy_hint: Option, + ) -> Self { + Self { + protocol, + host, + port, + client_addr, + method, + command, + exec_policy_hint, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum NetworkDecision { + Allow, + Deny { reason: String }, +} + +impl NetworkDecision { + pub fn deny(reason: impl Into) -> Self { + let reason = reason.into(); + let reason = if reason.is_empty() { + REASON_POLICY_DENIED.to_string() + } else { + reason + }; + Self::Deny { reason } + } +} + +/// Decide whether a network request should be allowed. +/// +/// If `command` or `exec_policy_hint` is provided, callers can map exec-policy +/// approvals to network access (e.g., allow all requests for commands matching +/// approved prefixes like `curl *`). +#[async_trait] +pub trait NetworkPolicyDecider: Send + Sync + 'static { + async fn decide(&self, req: NetworkPolicyRequest) -> NetworkDecision; +} + +#[async_trait] +impl NetworkPolicyDecider for Arc { + async fn decide(&self, req: NetworkPolicyRequest) -> NetworkDecision { + (**self).decide(req).await + } +} + +#[async_trait] +impl NetworkPolicyDecider for F +where + F: Fn(NetworkPolicyRequest) -> Fut + Send + Sync + 'static, + Fut: Future + Send, +{ + async fn decide(&self, req: NetworkPolicyRequest) -> NetworkDecision { + (self)(req).await + } +} + +pub(crate) async fn evaluate_host_policy( + state: &NetworkProxyState, + decider: Option<&Arc>, + request: &NetworkPolicyRequest, +) -> Result { + match state.host_blocked(&request.host, request.port).await? { + HostBlockDecision::Allowed => Ok(NetworkDecision::Allow), + HostBlockDecision::Blocked(HostBlockReason::NotAllowed) => { + if let Some(decider) = decider { + Ok(decider.decide(request.clone()).await) + } else { + Ok(NetworkDecision::deny(HostBlockReason::NotAllowed.as_str())) + } + } + HostBlockDecision::Blocked(reason) => Ok(NetworkDecision::deny(reason.as_str())), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::config::NetworkPolicy; + use crate::reasons::REASON_DENIED; + use crate::reasons::REASON_NOT_ALLOWED_LOCAL; + use crate::state::network_proxy_state_for_policy; + use pretty_assertions::assert_eq; + use std::sync::Arc; + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + + #[tokio::test] + async fn evaluate_host_policy_invokes_decider_for_not_allowed() { + let state = network_proxy_state_for_policy(NetworkPolicy::default()); + let calls = Arc::new(AtomicUsize::new(0)); + let decider: Arc = Arc::new({ + let calls = calls.clone(); + move |_req| { + calls.fetch_add(1, Ordering::SeqCst); + // The default policy denies all; the decider is consulted for not_allowed + // requests and can override that decision. + async { NetworkDecision::Allow } + } + }); + + let request = NetworkPolicyRequest::new( + NetworkProtocol::Http, + "example.com".to_string(), + 80, + None, + Some("GET".to_string()), + None, + None, + ); + + let decision = evaluate_host_policy(&state, Some(&decider), &request) + .await + .unwrap(); + assert_eq!(decision, NetworkDecision::Allow); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn evaluate_host_policy_skips_decider_for_denied() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["example.com".to_string()], + denied_domains: vec!["blocked.com".to_string()], + ..NetworkPolicy::default() + }); + let calls = Arc::new(AtomicUsize::new(0)); + let decider: Arc = Arc::new({ + let calls = calls.clone(); + move |_req| { + calls.fetch_add(1, Ordering::SeqCst); + async { NetworkDecision::Allow } + } + }); + + let request = NetworkPolicyRequest::new( + NetworkProtocol::Http, + "blocked.com".to_string(), + 80, + None, + Some("GET".to_string()), + None, + None, + ); + + let decision = evaluate_host_policy(&state, Some(&decider), &request) + .await + .unwrap(); + assert_eq!( + decision, + NetworkDecision::Deny { + reason: REASON_DENIED.to_string() + } + ); + assert_eq!(calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn evaluate_host_policy_skips_decider_for_not_allowed_local() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["example.com".to_string()], + allow_local_binding: false, + ..NetworkPolicy::default() + }); + let calls = Arc::new(AtomicUsize::new(0)); + let decider: Arc = Arc::new({ + let calls = calls.clone(); + move |_req| { + calls.fetch_add(1, Ordering::SeqCst); + async { NetworkDecision::Allow } + } + }); + + let request = NetworkPolicyRequest::new( + NetworkProtocol::Http, + "127.0.0.1".to_string(), + 80, + None, + Some("GET".to_string()), + None, + None, + ); + + let decision = evaluate_host_policy(&state, Some(&decider), &request) + .await + .unwrap(); + assert_eq!( + decision, + NetworkDecision::Deny { + reason: REASON_NOT_ALLOWED_LOCAL.to_string() + } + ); + assert_eq!(calls.load(Ordering::SeqCst), 0); + } +} diff --git a/codex-rs/network-proxy/src/policy.rs b/codex-rs/network-proxy/src/policy.rs new file mode 100644 index 0000000000..d663aea065 --- /dev/null +++ b/codex-rs/network-proxy/src/policy.rs @@ -0,0 +1,435 @@ +#[cfg(test)] +use crate::config::NetworkMode; +use anyhow::Context; +use anyhow::Result; +use anyhow::ensure; +use globset::GlobBuilder; +use globset::GlobSet; +use globset::GlobSetBuilder; +use std::collections::HashSet; +use std::net::IpAddr; +use std::net::Ipv4Addr; +use std::net::Ipv6Addr; +use url::Host as UrlHost; + +/// A normalized host string for policy evaluation. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct Host(String); + +impl Host { + pub fn parse(input: &str) -> Result { + let normalized = normalize_host(input); + ensure!(!normalized.is_empty(), "host is empty"); + Ok(Self(normalized)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Returns true if the host is a loopback hostname or IP literal. +pub fn is_loopback_host(host: &Host) -> bool { + let host = host.as_str(); + let host = host.split_once('%').map(|(ip, _)| ip).unwrap_or(host); + if host == "localhost" { + return true; + } + if let Ok(ip) = host.parse::() { + return ip.is_loopback(); + } + false +} + +pub fn is_non_public_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(ip) => is_non_public_ipv4(ip), + IpAddr::V6(ip) => is_non_public_ipv6(ip), + } +} + +fn is_non_public_ipv4(ip: Ipv4Addr) -> bool { + // Use the standard library classification helpers where possible; they encode the intent more + // clearly than hand-rolled range checks. Some non-public ranges (e.g., CGNAT and TEST-NET + // blocks) are not covered by stable stdlib helpers yet, so we fall back to CIDR checks. + ip.is_loopback() + || ip.is_private() + || ip.is_link_local() + || ip.is_unspecified() + || ip.is_multicast() + || ip.is_broadcast() + || ipv4_in_cidr(ip, [0, 0, 0, 0], 8) // "this network" (RFC 1122) + || ipv4_in_cidr(ip, [100, 64, 0, 0], 10) // CGNAT (RFC 6598) + || ipv4_in_cidr(ip, [192, 0, 0, 0], 24) // IETF Protocol Assignments (RFC 6890) + || ipv4_in_cidr(ip, [192, 0, 2, 0], 24) // TEST-NET-1 (RFC 5737) + || ipv4_in_cidr(ip, [198, 18, 0, 0], 15) // Benchmarking (RFC 2544) + || ipv4_in_cidr(ip, [198, 51, 100, 0], 24) // TEST-NET-2 (RFC 5737) + || ipv4_in_cidr(ip, [203, 0, 113, 0], 24) // TEST-NET-3 (RFC 5737) + || ipv4_in_cidr(ip, [240, 0, 0, 0], 4) // Reserved (RFC 6890) +} + +fn ipv4_in_cidr(ip: Ipv4Addr, base: [u8; 4], prefix: u8) -> bool { + let ip = u32::from(ip); + let base = u32::from(Ipv4Addr::from(base)); + let mask = if prefix == 0 { + 0 + } else { + u32::MAX << (32 - prefix) + }; + (ip & mask) == (base & mask) +} + +fn is_non_public_ipv6(ip: Ipv6Addr) -> bool { + if let Some(v4) = ip.to_ipv4() { + return is_non_public_ipv4(v4) || ip.is_loopback(); + } + // Treat anything that isn't globally routable as "local" for SSRF prevention. In particular: + // - `::1` loopback + // - `fc00::/7` unique-local (RFC 4193) + // - `fe80::/10` link-local + // - `::` unspecified + // - multicast ranges + ip.is_loopback() + || ip.is_unspecified() + || ip.is_multicast() + || ip.is_unique_local() + || ip.is_unicast_link_local() +} + +/// Normalize host fragments for policy matching (trim whitespace, strip ports/brackets, lowercase). +pub fn normalize_host(host: &str) -> String { + let host = host.trim(); + if host.starts_with('[') + && let Some(end) = host.find(']') + { + return normalize_dns_host(&host[1..end]); + } + + // The proxy stack should typically hand us a host without a port, but be + // defensive and strip `:port` when there is exactly one `:`. + if host.bytes().filter(|b| *b == b':').count() == 1 { + let host = host.split(':').next().unwrap_or_default(); + return normalize_dns_host(host); + } + + // Avoid mangling unbracketed IPv6 literals, but strip trailing dots so fully qualified domain + // names are treated the same as their dotless variants. + normalize_dns_host(host) +} + +fn normalize_dns_host(host: &str) -> String { + let host = host.to_ascii_lowercase(); + host.trim_end_matches('.').to_string() +} + +fn normalize_pattern(pattern: &str) -> String { + let pattern = pattern.trim(); + if pattern == "*" { + return "*".to_string(); + } + + let (prefix, remainder) = if let Some(domain) = pattern.strip_prefix("**.") { + ("**.", domain) + } else if let Some(domain) = pattern.strip_prefix("*.") { + ("*.", domain) + } else { + ("", pattern) + }; + + let remainder = normalize_host(remainder); + if prefix.is_empty() { + remainder + } else { + format!("{prefix}{remainder}") + } +} + +pub(crate) fn compile_globset(patterns: &[String]) -> Result { + let mut builder = GlobSetBuilder::new(); + let mut seen = HashSet::new(); + for pattern in patterns { + let pattern = normalize_pattern(pattern); + // Supported domain patterns: + // - "example.com": match the exact host + // - "*.example.com": match any subdomain (not the apex) + // - "**.example.com": match the apex and any subdomain + // - "*": match any host + for candidate in expand_domain_pattern(&pattern) { + if !seen.insert(candidate.clone()) { + continue; + } + let glob = GlobBuilder::new(&candidate) + .case_insensitive(true) + .build() + .with_context(|| format!("invalid glob pattern: {candidate}"))?; + builder.add(glob); + } + } + Ok(builder.build()?) +} + +#[derive(Debug, Clone)] +pub(crate) enum DomainPattern { + Any, + ApexAndSubdomains(String), + SubdomainsOnly(String), + Exact(String), +} + +impl DomainPattern { + /// Parse a policy pattern for constraint comparisons. + /// + /// Validation of glob syntax happens when building the globset; here we only + /// decode the wildcard prefixes to keep constraint checks lightweight. + pub(crate) fn parse(input: &str) -> Self { + let input = input.trim(); + if input.is_empty() { + return Self::Exact(String::new()); + } + if input == "*" { + Self::Any + } else if let Some(domain) = input.strip_prefix("**.") { + Self::parse_domain(domain, Self::ApexAndSubdomains) + } else if let Some(domain) = input.strip_prefix("*.") { + Self::parse_domain(domain, Self::SubdomainsOnly) + } else { + Self::Exact(input.to_string()) + } + } + + /// Parse a policy pattern for constraint comparisons, validating domain parts with `url`. + pub(crate) fn parse_for_constraints(input: &str) -> Self { + let input = input.trim(); + if input.is_empty() { + return Self::Exact(String::new()); + } + if input == "*" { + return Self::Any; + } + if let Some(domain) = input.strip_prefix("**.") { + return Self::ApexAndSubdomains(parse_domain_for_constraints(domain)); + } + if let Some(domain) = input.strip_prefix("*.") { + return Self::SubdomainsOnly(parse_domain_for_constraints(domain)); + } + Self::Exact(parse_domain_for_constraints(input)) + } + + fn parse_domain(domain: &str, build: impl FnOnce(String) -> Self) -> Self { + let domain = domain.trim(); + if domain.is_empty() { + return Self::Exact(String::new()); + } + build(domain.to_string()) + } + + pub(crate) fn allows(&self, candidate: &DomainPattern) -> bool { + match self { + DomainPattern::Any => true, + DomainPattern::Exact(domain) => match candidate { + DomainPattern::Exact(candidate) => domain_eq(candidate, domain), + _ => false, + }, + DomainPattern::SubdomainsOnly(domain) => match candidate { + DomainPattern::Any => false, + DomainPattern::Exact(candidate) => is_strict_subdomain(candidate, domain), + DomainPattern::SubdomainsOnly(candidate) => { + is_subdomain_or_equal(candidate, domain) + } + DomainPattern::ApexAndSubdomains(candidate) => { + is_strict_subdomain(candidate, domain) + } + }, + DomainPattern::ApexAndSubdomains(domain) => match candidate { + DomainPattern::Any => false, + DomainPattern::Exact(candidate) => is_subdomain_or_equal(candidate, domain), + DomainPattern::SubdomainsOnly(candidate) => { + is_subdomain_or_equal(candidate, domain) + } + DomainPattern::ApexAndSubdomains(candidate) => { + is_subdomain_or_equal(candidate, domain) + } + }, + } + } +} + +fn parse_domain_for_constraints(domain: &str) -> String { + let domain = domain.trim().trim_end_matches('.'); + if domain.is_empty() { + return String::new(); + } + let host = if domain.starts_with('[') && domain.ends_with(']') { + &domain[1..domain.len().saturating_sub(1)] + } else { + domain + }; + if host.contains('*') || host.contains('?') || host.contains('%') { + return domain.to_string(); + } + match UrlHost::parse(host) { + Ok(host) => host.to_string(), + Err(_) => String::new(), + } +} + +fn expand_domain_pattern(pattern: &str) -> Vec { + match DomainPattern::parse(pattern) { + DomainPattern::Any => vec![pattern.to_string()], + DomainPattern::Exact(domain) => vec![domain], + DomainPattern::SubdomainsOnly(domain) => { + vec![format!("?*.{domain}")] + } + DomainPattern::ApexAndSubdomains(domain) => { + vec![domain.clone(), format!("?*.{domain}")] + } + } +} + +fn normalize_domain(domain: &str) -> String { + domain.trim_end_matches('.').to_ascii_lowercase() +} + +fn domain_eq(left: &str, right: &str) -> bool { + normalize_domain(left) == normalize_domain(right) +} + +fn is_subdomain_or_equal(child: &str, parent: &str) -> bool { + let child = normalize_domain(child); + let parent = normalize_domain(parent); + if child == parent { + return true; + } + child.ends_with(&format!(".{parent}")) +} + +fn is_strict_subdomain(child: &str, parent: &str) -> bool { + let child = normalize_domain(child); + let parent = normalize_domain(parent); + child != parent && child.ends_with(&format!(".{parent}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + use pretty_assertions::assert_eq; + + #[test] + fn method_allowed_full_allows_everything() { + assert!(NetworkMode::Full.allows_method("GET")); + assert!(NetworkMode::Full.allows_method("POST")); + assert!(NetworkMode::Full.allows_method("CONNECT")); + } + + #[test] + fn method_allowed_limited_allows_only_safe_methods() { + assert!(NetworkMode::Limited.allows_method("GET")); + assert!(NetworkMode::Limited.allows_method("HEAD")); + assert!(NetworkMode::Limited.allows_method("OPTIONS")); + assert!(!NetworkMode::Limited.allows_method("POST")); + assert!(!NetworkMode::Limited.allows_method("CONNECT")); + } + + #[test] + fn compile_globset_normalizes_trailing_dots() { + let set = compile_globset(&["Example.COM.".to_string()]).unwrap(); + + assert_eq!(true, set.is_match("example.com")); + assert_eq!(false, set.is_match("api.example.com")); + } + + #[test] + fn compile_globset_normalizes_wildcards() { + let set = compile_globset(&["*.Example.COM.".to_string()]).unwrap(); + + assert_eq!(true, set.is_match("api.example.com")); + assert_eq!(false, set.is_match("example.com")); + } + + #[test] + fn compile_globset_normalizes_apex_and_subdomains() { + let set = compile_globset(&["**.Example.COM.".to_string()]).unwrap(); + + assert_eq!(true, set.is_match("example.com")); + assert_eq!(true, set.is_match("api.example.com")); + } + + #[test] + fn compile_globset_normalizes_bracketed_ipv6_literals() { + let set = compile_globset(&["[::1]".to_string()]).unwrap(); + + assert_eq!(true, set.is_match("::1")); + } + + #[test] + fn is_loopback_host_handles_localhost_variants() { + assert!(is_loopback_host(&Host::parse("localhost").unwrap())); + assert!(is_loopback_host(&Host::parse("localhost.").unwrap())); + assert!(is_loopback_host(&Host::parse("LOCALHOST").unwrap())); + assert!(!is_loopback_host(&Host::parse("notlocalhost").unwrap())); + } + + #[test] + fn is_loopback_host_handles_ip_literals() { + assert!(is_loopback_host(&Host::parse("127.0.0.1").unwrap())); + assert!(is_loopback_host(&Host::parse("::1").unwrap())); + assert!(!is_loopback_host(&Host::parse("1.2.3.4").unwrap())); + } + + #[test] + fn is_non_public_ip_rejects_private_and_loopback_ranges() { + assert!(is_non_public_ip("127.0.0.1".parse().unwrap())); + assert!(is_non_public_ip("10.0.0.1".parse().unwrap())); + assert!(is_non_public_ip("192.168.0.1".parse().unwrap())); + assert!(is_non_public_ip("100.64.0.1".parse().unwrap())); + assert!(is_non_public_ip("192.0.0.1".parse().unwrap())); + assert!(is_non_public_ip("192.0.2.1".parse().unwrap())); + assert!(is_non_public_ip("198.18.0.1".parse().unwrap())); + assert!(is_non_public_ip("198.51.100.1".parse().unwrap())); + assert!(is_non_public_ip("203.0.113.1".parse().unwrap())); + assert!(is_non_public_ip("240.0.0.1".parse().unwrap())); + assert!(is_non_public_ip("0.1.2.3".parse().unwrap())); + assert!(!is_non_public_ip("8.8.8.8".parse().unwrap())); + + assert!(is_non_public_ip("::ffff:127.0.0.1".parse().unwrap())); + assert!(is_non_public_ip("::ffff:10.0.0.1".parse().unwrap())); + assert!(!is_non_public_ip("::ffff:8.8.8.8".parse().unwrap())); + + assert!(is_non_public_ip("::1".parse().unwrap())); + assert!(is_non_public_ip("fe80::1".parse().unwrap())); + assert!(is_non_public_ip("fc00::1".parse().unwrap())); + } + + #[test] + fn normalize_host_lowercases_and_trims() { + assert_eq!(normalize_host(" ExAmPlE.CoM "), "example.com"); + } + + #[test] + fn normalize_host_strips_port_for_host_port() { + assert_eq!(normalize_host("example.com:1234"), "example.com"); + } + + #[test] + fn normalize_host_preserves_unbracketed_ipv6() { + assert_eq!(normalize_host("2001:db8::1"), "2001:db8::1"); + } + + #[test] + fn normalize_host_strips_trailing_dot() { + assert_eq!(normalize_host("example.com."), "example.com"); + assert_eq!(normalize_host("ExAmPlE.CoM."), "example.com"); + } + + #[test] + fn normalize_host_strips_trailing_dot_with_port() { + assert_eq!(normalize_host("example.com.:443"), "example.com"); + } + + #[test] + fn normalize_host_strips_brackets_for_ipv6() { + assert_eq!(normalize_host("[::1]"), "::1"); + assert_eq!(normalize_host("[::1]:443"), "::1"); + } +} diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs new file mode 100644 index 0000000000..480880e946 --- /dev/null +++ b/codex-rs/network-proxy/src/proxy.rs @@ -0,0 +1,176 @@ +use crate::admin; +use crate::config; +use crate::http_proxy; +use crate::network_policy::NetworkPolicyDecider; +use crate::runtime::unix_socket_permissions_supported; +use crate::state::NetworkProxyState; +use anyhow::Context; +use anyhow::Result; +use clap::Parser; +use std::net::SocketAddr; +use std::sync::Arc; +use tokio::task::JoinHandle; +use tracing::warn; + +#[derive(Debug, Clone, Parser)] +#[command(name = "codex-network-proxy", about = "Codex network sandbox proxy")] +pub struct Args {} + +#[derive(Clone, Default)] +pub struct NetworkProxyBuilder { + state: Option>, + http_addr: Option, + admin_addr: Option, + policy_decider: Option>, +} + +impl NetworkProxyBuilder { + pub fn state(mut self, state: Arc) -> Self { + self.state = Some(state); + self + } + + pub fn http_addr(mut self, addr: SocketAddr) -> Self { + self.http_addr = Some(addr); + self + } + + pub fn admin_addr(mut self, addr: SocketAddr) -> Self { + self.admin_addr = Some(addr); + self + } + + pub fn policy_decider(mut self, decider: D) -> Self + where + D: NetworkPolicyDecider, + { + self.policy_decider = Some(Arc::new(decider)); + self + } + + pub fn policy_decider_arc(mut self, decider: Arc) -> Self { + self.policy_decider = Some(decider); + self + } + + pub async fn build(self) -> Result { + let state = match self.state { + Some(state) => state, + None => Arc::new(NetworkProxyState::new().await?), + }; + let current_cfg = state.current_cfg().await?; + let runtime = config::resolve_runtime(¤t_cfg)?; + // Reapply bind clamping for caller overrides so unix-socket proxying stays loopback-only. + let (http_addr, admin_addr) = config::clamp_bind_addrs( + self.http_addr.unwrap_or(runtime.http_addr), + self.admin_addr.unwrap_or(runtime.admin_addr), + ¤t_cfg.network_proxy, + ); + + Ok(NetworkProxy { + state, + http_addr, + admin_addr, + policy_decider: self.policy_decider, + }) + } +} + +#[derive(Clone)] +pub struct NetworkProxy { + state: Arc, + http_addr: SocketAddr, + admin_addr: SocketAddr, + policy_decider: Option>, +} + +impl NetworkProxy { + pub fn builder() -> NetworkProxyBuilder { + NetworkProxyBuilder::default() + } + + pub async fn run(&self) -> Result { + let current_cfg = self.state.current_cfg().await?; + if !current_cfg.network_proxy.enabled { + warn!("network_proxy.enabled is false; skipping proxy listeners"); + return Ok(NetworkProxyHandle::noop()); + } + + if !unix_socket_permissions_supported() { + warn!("allowUnixSockets is macOS-only; requests will be rejected on this platform"); + } + + let http_task = tokio::spawn(http_proxy::run_http_proxy( + self.state.clone(), + self.http_addr, + self.policy_decider.clone(), + )); + let admin_task = tokio::spawn(admin::run_admin_api(self.state.clone(), self.admin_addr)); + + Ok(NetworkProxyHandle { + http_task: Some(http_task), + admin_task: Some(admin_task), + completed: false, + }) + } +} + +pub struct NetworkProxyHandle { + http_task: Option>>, + admin_task: Option>>, + completed: bool, +} + +impl NetworkProxyHandle { + fn noop() -> Self { + Self { + http_task: Some(tokio::spawn(async { Ok(()) })), + admin_task: Some(tokio::spawn(async { Ok(()) })), + completed: true, + } + } + + pub async fn wait(mut self) -> Result<()> { + let http_task = self.http_task.take().context("missing http proxy task")?; + let admin_task = self.admin_task.take().context("missing admin proxy task")?; + let http_result = http_task.await; + let admin_result = admin_task.await; + self.completed = true; + http_result??; + admin_result??; + Ok(()) + } + + pub async fn shutdown(mut self) -> Result<()> { + abort_tasks(self.http_task.take(), self.admin_task.take()).await; + self.completed = true; + Ok(()) + } +} + +async fn abort_tasks( + http_task: Option>>, + admin_task: Option>>, +) { + if let Some(http_task) = http_task { + http_task.abort(); + let _ = http_task.await; + } + if let Some(admin_task) = admin_task { + admin_task.abort(); + let _ = admin_task.await; + } +} + +impl Drop for NetworkProxyHandle { + fn drop(&mut self) { + if self.completed { + return; + } + let http_task = self.http_task.take(); + let admin_task = self.admin_task.take(); + tokio::spawn(async move { + abort_tasks(http_task, admin_task).await; + }); + } +} diff --git a/codex-rs/network-proxy/src/reasons.rs b/codex-rs/network-proxy/src/reasons.rs new file mode 100644 index 0000000000..99f7f742d0 --- /dev/null +++ b/codex-rs/network-proxy/src/reasons.rs @@ -0,0 +1,6 @@ +pub(crate) const REASON_DENIED: &str = "denied"; +pub(crate) const REASON_METHOD_NOT_ALLOWED: &str = "method_not_allowed"; +pub(crate) const REASON_NOT_ALLOWED: &str = "not_allowed"; +pub(crate) const REASON_NOT_ALLOWED_LOCAL: &str = "not_allowed_local"; +pub(crate) const REASON_POLICY_DENIED: &str = "policy_denied"; +pub(crate) const REASON_PROXY_DISABLED: &str = "proxy_disabled"; diff --git a/codex-rs/network-proxy/src/responses.rs b/codex-rs/network-proxy/src/responses.rs new file mode 100644 index 0000000000..d02d1910e8 --- /dev/null +++ b/codex-rs/network-proxy/src/responses.rs @@ -0,0 +1,67 @@ +use crate::reasons::REASON_DENIED; +use crate::reasons::REASON_METHOD_NOT_ALLOWED; +use crate::reasons::REASON_NOT_ALLOWED; +use crate::reasons::REASON_NOT_ALLOWED_LOCAL; +use rama_http::Body; +use rama_http::Response; +use rama_http::StatusCode; +use serde::Serialize; +use tracing::error; + +pub fn text_response(status: StatusCode, body: &str) -> Response { + Response::builder() + .status(status) + .header("content-type", "text/plain") + .body(Body::from(body.to_string())) + .unwrap_or_else(|_| Response::new(Body::from(body.to_string()))) +} + +pub fn json_response(value: &T) -> Response { + let body = match serde_json::to_string(value) { + Ok(body) => body, + Err(err) => { + error!("failed to serialize JSON response: {err}"); + "{}".to_string() + } + }; + Response::builder() + .status(StatusCode::OK) + .header("content-type", "application/json") + .body(Body::from(body)) + .unwrap_or_else(|err| { + error!("failed to build JSON response: {err}"); + Response::new(Body::from("{}")) + }) +} + +pub fn blocked_header_value(reason: &str) -> &'static str { + match reason { + REASON_NOT_ALLOWED | REASON_NOT_ALLOWED_LOCAL => "blocked-by-allowlist", + REASON_DENIED => "blocked-by-denylist", + REASON_METHOD_NOT_ALLOWED => "blocked-by-method-policy", + _ => "blocked-by-policy", + } +} + +pub fn blocked_message(reason: &str) -> &'static str { + match reason { + REASON_NOT_ALLOWED => "Codex blocked this request: domain not in allowlist.", + REASON_NOT_ALLOWED_LOCAL => { + "Codex blocked this request: local/private addresses not allowed." + } + REASON_DENIED => "Codex blocked this request: domain denied by policy.", + REASON_METHOD_NOT_ALLOWED => { + "Codex blocked this request: method not allowed in limited mode." + } + _ => "Codex blocked this request by network policy.", + } +} + +pub fn blocked_text_response(reason: &str) -> Response { + Response::builder() + .status(StatusCode::FORBIDDEN) + .header("content-type", "text/plain") + .header("x-proxy-error", blocked_header_value(reason)) + .body(Body::from(blocked_message(reason))) + .unwrap_or_else(|_| Response::new(Body::from("blocked"))) +} diff --git a/codex-rs/network-proxy/src/runtime.rs b/codex-rs/network-proxy/src/runtime.rs new file mode 100644 index 0000000000..49e34d5c0a --- /dev/null +++ b/codex-rs/network-proxy/src/runtime.rs @@ -0,0 +1,984 @@ +use crate::config::NetworkMode; +use crate::config::NetworkProxyConfig; +use crate::policy::Host; +use crate::policy::is_loopback_host; +use crate::policy::is_non_public_ip; +use crate::policy::normalize_host; +use crate::reasons::REASON_DENIED; +use crate::reasons::REASON_NOT_ALLOWED; +use crate::reasons::REASON_NOT_ALLOWED_LOCAL; +use crate::state::NetworkProxyConstraints; +use crate::state::build_config_state; +use crate::state::validate_policy_against_constraints; +use anyhow::Context; +use anyhow::Result; +use codex_utils_absolute_path::AbsolutePathBuf; +use globset::GlobSet; +use serde::Serialize; +use std::collections::HashSet; +use std::collections::VecDeque; +use std::net::IpAddr; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use std::time::SystemTime; +use time::OffsetDateTime; +use tokio::net::lookup_host; +use tokio::sync::RwLock; +use tokio::time::timeout; +use tracing::info; +use tracing::warn; + +const MAX_BLOCKED_EVENTS: usize = 200; +const DNS_LOOKUP_TIMEOUT: Duration = Duration::from_secs(2); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HostBlockReason { + Denied, + NotAllowed, + NotAllowedLocal, +} + +impl HostBlockReason { + pub const fn as_str(self) -> &'static str { + match self { + Self::Denied => REASON_DENIED, + Self::NotAllowed => REASON_NOT_ALLOWED, + Self::NotAllowedLocal => REASON_NOT_ALLOWED_LOCAL, + } + } +} + +impl std::fmt::Display for HostBlockReason { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HostBlockDecision { + Allowed, + Blocked(HostBlockReason), +} + +#[derive(Clone, Debug, Serialize)] +pub struct BlockedRequest { + pub host: String, + pub reason: String, + pub client: Option, + pub method: Option, + pub mode: Option, + pub protocol: String, + pub timestamp: i64, +} + +impl BlockedRequest { + pub fn new( + host: String, + reason: String, + client: Option, + method: Option, + mode: Option, + protocol: String, + ) -> Self { + Self { + host, + reason, + client, + method, + mode, + protocol, + timestamp: unix_timestamp(), + } + } +} + +#[derive(Clone)] +pub(crate) struct ConfigState { + pub(crate) config: NetworkProxyConfig, + pub(crate) allow_set: GlobSet, + pub(crate) deny_set: GlobSet, + pub(crate) constraints: NetworkProxyConstraints, + pub(crate) layer_mtimes: Vec, + pub(crate) cfg_path: PathBuf, + pub(crate) blocked: VecDeque, +} + +#[derive(Clone)] +pub(crate) struct LayerMtime { + pub(crate) path: PathBuf, + pub(crate) mtime: Option, +} + +impl LayerMtime { + pub(crate) fn new(path: PathBuf) -> Self { + let mtime = path.metadata().and_then(|m| m.modified()).ok(); + Self { path, mtime } + } +} + +#[derive(Clone)] +pub struct NetworkProxyState { + state: Arc>, +} + +impl std::fmt::Debug for NetworkProxyState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Avoid logging internal state (config contents, derived globsets, etc.) which can be noisy + // and may contain sensitive paths. + f.debug_struct("NetworkProxyState").finish_non_exhaustive() + } +} + +impl NetworkProxyState { + pub async fn new() -> Result { + let cfg_state = build_config_state().await?; + Ok(Self { + state: Arc::new(RwLock::new(cfg_state)), + }) + } + + pub async fn current_cfg(&self) -> Result { + // Callers treat `NetworkProxyState` as a live view of policy. We reload-on-demand so edits to + // `config.toml` (including Codex-managed writes) take effect without a restart. + self.reload_if_needed().await?; + let guard = self.state.read().await; + Ok(guard.config.clone()) + } + + pub async fn current_patterns(&self) -> Result<(Vec, Vec)> { + self.reload_if_needed().await?; + let guard = self.state.read().await; + Ok(( + guard.config.network_proxy.policy.allowed_domains.clone(), + guard.config.network_proxy.policy.denied_domains.clone(), + )) + } + + pub async fn enabled(&self) -> Result { + self.reload_if_needed().await?; + let guard = self.state.read().await; + Ok(guard.config.network_proxy.enabled) + } + + pub async fn force_reload(&self) -> Result<()> { + let (previous_cfg, cfg_path) = { + let guard = self.state.read().await; + (guard.config.clone(), guard.cfg_path.clone()) + }; + + match build_config_state().await { + Ok(mut new_state) => { + // Policy changes are operationally sensitive; logging diffs makes changes traceable + // without needing to dump full config blobs (which can include unrelated settings). + log_policy_changes(&previous_cfg, &new_state.config); + let mut guard = self.state.write().await; + new_state.blocked = guard.blocked.clone(); + *guard = new_state; + let path = guard.cfg_path.display(); + info!("reloaded config from {path}"); + Ok(()) + } + Err(err) => { + let path = cfg_path.display(); + warn!("failed to reload config from {path}: {err}; keeping previous config"); + Err(err) + } + } + } + + pub async fn host_blocked(&self, host: &str, port: u16) -> Result { + self.reload_if_needed().await?; + let host = match Host::parse(host) { + Ok(host) => host, + Err(_) => return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowed)), + }; + let (deny_set, allow_set, allow_local_binding, allowed_domains_empty, allowed_domains) = { + let guard = self.state.read().await; + ( + guard.deny_set.clone(), + guard.allow_set.clone(), + guard.config.network_proxy.policy.allow_local_binding, + guard.config.network_proxy.policy.allowed_domains.is_empty(), + guard.config.network_proxy.policy.allowed_domains.clone(), + ) + }; + + let host_str = host.as_str(); + + // Decision order matters: + // 1) explicit deny always wins + // 2) local/private networking is opt-in (defense-in-depth) + // 3) allowlist is enforced when configured + if deny_set.is_match(host_str) { + return Ok(HostBlockDecision::Blocked(HostBlockReason::Denied)); + } + + let is_allowlisted = allow_set.is_match(host_str); + if !allow_local_binding { + // If the intent is "prevent access to local/internal networks", we must not rely solely + // on string checks like `localhost` / `127.0.0.1`. Attackers can use DNS rebinding or + // public suffix services that map hostnames onto private IPs. + // + // We therefore do a best-effort DNS + IP classification check before allowing the + // request. Explicit local/loopback literals are allowed only when explicitly + // allowlisted; hostnames that resolve to local/private IPs are blocked even if + // allowlisted. + let local_literal = { + let host_no_scope = host_str + .split_once('%') + .map(|(ip, _)| ip) + .unwrap_or(host_str); + if is_loopback_host(&host) { + true + } else if let Ok(ip) = host_no_scope.parse::() { + is_non_public_ip(ip) + } else { + false + } + }; + + if local_literal { + if !is_explicit_local_allowlisted(&allowed_domains, &host) { + return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)); + } + } else if host_resolves_to_non_public_ip(host_str, port).await { + return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)); + } + } + + if allowed_domains_empty || !is_allowlisted { + Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowed)) + } else { + Ok(HostBlockDecision::Allowed) + } + } + + pub async fn record_blocked(&self, entry: BlockedRequest) -> Result<()> { + self.reload_if_needed().await?; + let mut guard = self.state.write().await; + guard.blocked.push_back(entry); + while guard.blocked.len() > MAX_BLOCKED_EVENTS { + guard.blocked.pop_front(); + } + Ok(()) + } + + /// Drain and return the buffered blocked-request entries in FIFO order. + pub async fn drain_blocked(&self) -> Result> { + self.reload_if_needed().await?; + let blocked = { + let mut guard = self.state.write().await; + std::mem::take(&mut guard.blocked) + }; + Ok(blocked.into_iter().collect()) + } + + pub async fn is_unix_socket_allowed(&self, path: &str) -> Result { + self.reload_if_needed().await?; + if !unix_socket_permissions_supported() { + return Ok(false); + } + + // We only support absolute unix socket paths (a relative path would be ambiguous with + // respect to the proxy process's CWD and can lead to confusing allowlist behavior). + let requested_path = Path::new(path); + if !requested_path.is_absolute() { + return Ok(false); + } + + let guard = self.state.read().await; + // Normalize the path while keeping the absolute-path requirement explicit. + let requested_abs = match AbsolutePathBuf::from_absolute_path(requested_path) { + Ok(path) => path, + Err(_) => return Ok(false), + }; + let requested_canonical = std::fs::canonicalize(requested_abs.as_path()).ok(); + for allowed in &guard.config.network_proxy.policy.allow_unix_sockets { + if allowed == path { + return Ok(true); + } + + // Best-effort canonicalization to reduce surprises with symlinks. + // If canonicalization fails (e.g., socket not created yet), fall back to raw comparison. + let Some(requested_canonical) = &requested_canonical else { + continue; + }; + if let Ok(allowed_canonical) = std::fs::canonicalize(allowed) + && &allowed_canonical == requested_canonical + { + return Ok(true); + } + } + Ok(false) + } + + pub async fn method_allowed(&self, method: &str) -> Result { + self.reload_if_needed().await?; + let guard = self.state.read().await; + Ok(guard.config.network_proxy.mode.allows_method(method)) + } + + pub async fn allow_upstream_proxy(&self) -> Result { + self.reload_if_needed().await?; + let guard = self.state.read().await; + Ok(guard.config.network_proxy.allow_upstream_proxy) + } + + pub async fn network_mode(&self) -> Result { + self.reload_if_needed().await?; + let guard = self.state.read().await; + Ok(guard.config.network_proxy.mode) + } + + pub async fn set_network_mode(&self, mode: NetworkMode) -> Result<()> { + loop { + self.reload_if_needed().await?; + let (candidate, constraints) = { + let guard = self.state.read().await; + let mut candidate = guard.config.clone(); + candidate.network_proxy.mode = mode; + (candidate, guard.constraints.clone()) + }; + + validate_policy_against_constraints(&candidate, &constraints) + .context("network_proxy.mode constrained by managed config")?; + + let mut guard = self.state.write().await; + if guard.constraints != constraints { + drop(guard); + continue; + } + guard.config.network_proxy.mode = mode; + info!("updated network mode to {mode:?}"); + return Ok(()); + } + } + + async fn reload_if_needed(&self) -> Result<()> { + let needs_reload = { + let guard = self.state.read().await; + guard.layer_mtimes.iter().any(|layer| { + let metadata = std::fs::metadata(&layer.path).ok(); + match (metadata.and_then(|m| m.modified().ok()), layer.mtime) { + (Some(new_mtime), Some(old_mtime)) => new_mtime > old_mtime, + (Some(_), None) => true, + (None, Some(_)) => true, + (None, None) => false, + } + }) + }; + + if !needs_reload { + return Ok(()); + } + + self.force_reload().await + } +} + +pub(crate) fn unix_socket_permissions_supported() -> bool { + cfg!(target_os = "macos") +} + +async fn host_resolves_to_non_public_ip(host: &str, port: u16) -> bool { + if let Ok(ip) = host.parse::() { + return is_non_public_ip(ip); + } + + // If DNS lookup fails, default to "not local/private" rather than blocking. In practice, the + // subsequent connect attempt will fail anyway, and blocking on transient resolver issues would + // make the proxy fragile. The allowlist/denylist remains the primary control plane. + let addrs = match timeout(DNS_LOOKUP_TIMEOUT, lookup_host((host, port))).await { + Ok(Ok(addrs)) => addrs, + Ok(Err(_)) | Err(_) => return false, + }; + + for addr in addrs { + if is_non_public_ip(addr.ip()) { + return true; + } + } + + false +} + +fn log_policy_changes(previous: &NetworkProxyConfig, next: &NetworkProxyConfig) { + log_domain_list_changes( + "allowlist", + &previous.network_proxy.policy.allowed_domains, + &next.network_proxy.policy.allowed_domains, + ); + log_domain_list_changes( + "denylist", + &previous.network_proxy.policy.denied_domains, + &next.network_proxy.policy.denied_domains, + ); +} + +fn log_domain_list_changes(list_name: &str, previous: &[String], next: &[String]) { + let previous_set: HashSet = previous + .iter() + .map(|entry| entry.to_ascii_lowercase()) + .collect(); + let next_set: HashSet = next + .iter() + .map(|entry| entry.to_ascii_lowercase()) + .collect(); + + let added = next_set + .difference(&previous_set) + .cloned() + .collect::>(); + let removed = previous_set + .difference(&next_set) + .cloned() + .collect::>(); + + let mut seen_next = HashSet::new(); + for entry in next { + let key = entry.to_ascii_lowercase(); + if seen_next.insert(key.clone()) && added.contains(&key) { + info!("config entry added to {list_name}: {entry}"); + } + } + + let mut seen_previous = HashSet::new(); + for entry in previous { + let key = entry.to_ascii_lowercase(); + if seen_previous.insert(key.clone()) && removed.contains(&key) { + info!("config entry removed from {list_name}: {entry}"); + } + } +} + +fn is_explicit_local_allowlisted(allowed_domains: &[String], host: &Host) -> bool { + let normalized_host = host.as_str(); + allowed_domains.iter().any(|pattern| { + let pattern = pattern.trim(); + if pattern == "*" || pattern.starts_with("*.") || pattern.starts_with("**.") { + return false; + } + if pattern.contains('*') || pattern.contains('?') { + return false; + } + normalize_host(pattern) == normalized_host + }) +} + +fn unix_timestamp() -> i64 { + OffsetDateTime::now_utc().unix_timestamp() +} + +#[cfg(test)] +pub(crate) fn network_proxy_state_for_policy( + policy: crate::config::NetworkPolicy, +) -> NetworkProxyState { + let config = NetworkProxyConfig { + network_proxy: crate::config::NetworkProxySettings { + enabled: true, + mode: NetworkMode::Full, + policy, + ..crate::config::NetworkProxySettings::default() + }, + }; + + let allow_set = + crate::policy::compile_globset(&config.network_proxy.policy.allowed_domains).unwrap(); + let deny_set = + crate::policy::compile_globset(&config.network_proxy.policy.denied_domains).unwrap(); + + let state = ConfigState { + config, + allow_set, + deny_set, + constraints: NetworkProxyConstraints::default(), + layer_mtimes: Vec::new(), + cfg_path: PathBuf::from("/nonexistent/config.toml"), + blocked: VecDeque::new(), + }; + + NetworkProxyState { + state: Arc::new(RwLock::new(state)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::config::NetworkPolicy; + use crate::config::NetworkProxyConfig; + use crate::config::NetworkProxySettings; + use crate::policy::compile_globset; + use crate::state::NetworkProxyConstraints; + use crate::state::validate_policy_against_constraints; + use pretty_assertions::assert_eq; + + #[tokio::test] + async fn host_blocked_denied_wins_over_allowed() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["example.com".to_string()], + denied_domains: vec!["example.com".to_string()], + ..NetworkPolicy::default() + }); + + assert_eq!( + state.host_blocked("example.com", 80).await.unwrap(), + HostBlockDecision::Blocked(HostBlockReason::Denied) + ); + } + + #[tokio::test] + async fn host_blocked_requires_allowlist_match() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["example.com".to_string()], + ..NetworkPolicy::default() + }); + + assert_eq!( + state.host_blocked("example.com", 80).await.unwrap(), + HostBlockDecision::Allowed + ); + assert_eq!( + // Use a public IP literal to avoid relying on ambient DNS behavior (some networks + // resolve unknown hostnames to private IPs, which would trigger `not_allowed_local`). + state.host_blocked("8.8.8.8", 80).await.unwrap(), + HostBlockDecision::Blocked(HostBlockReason::NotAllowed) + ); + } + + #[tokio::test] + async fn host_blocked_subdomain_wildcards_exclude_apex() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["*.openai.com".to_string()], + ..NetworkPolicy::default() + }); + + assert_eq!( + state.host_blocked("api.openai.com", 80).await.unwrap(), + HostBlockDecision::Allowed + ); + assert_eq!( + state.host_blocked("openai.com", 80).await.unwrap(), + HostBlockDecision::Blocked(HostBlockReason::NotAllowed) + ); + } + + #[tokio::test] + async fn host_blocked_rejects_loopback_when_local_binding_disabled() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["example.com".to_string()], + allow_local_binding: false, + ..NetworkPolicy::default() + }); + + assert_eq!( + state.host_blocked("127.0.0.1", 80).await.unwrap(), + HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal) + ); + assert_eq!( + state.host_blocked("localhost", 80).await.unwrap(), + HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal) + ); + } + + #[tokio::test] + async fn host_blocked_rejects_loopback_when_allowlist_is_wildcard() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["*".to_string()], + allow_local_binding: false, + ..NetworkPolicy::default() + }); + + assert_eq!( + state.host_blocked("127.0.0.1", 80).await.unwrap(), + HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal) + ); + } + + #[tokio::test] + async fn host_blocked_rejects_private_ip_literal_when_allowlist_is_wildcard() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["*".to_string()], + allow_local_binding: false, + ..NetworkPolicy::default() + }); + + assert_eq!( + state.host_blocked("10.0.0.1", 80).await.unwrap(), + HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal) + ); + } + + #[tokio::test] + async fn host_blocked_allows_loopback_when_explicitly_allowlisted_and_local_binding_disabled() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["localhost".to_string()], + allow_local_binding: false, + ..NetworkPolicy::default() + }); + + assert_eq!( + state.host_blocked("localhost", 80).await.unwrap(), + HostBlockDecision::Allowed + ); + } + + #[tokio::test] + async fn host_blocked_allows_private_ip_literal_when_explicitly_allowlisted() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["10.0.0.1".to_string()], + allow_local_binding: false, + ..NetworkPolicy::default() + }); + + assert_eq!( + state.host_blocked("10.0.0.1", 80).await.unwrap(), + HostBlockDecision::Allowed + ); + } + + #[tokio::test] + async fn host_blocked_rejects_scoped_ipv6_literal_when_not_allowlisted() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["example.com".to_string()], + allow_local_binding: false, + ..NetworkPolicy::default() + }); + + assert_eq!( + state.host_blocked("fe80::1%lo0", 80).await.unwrap(), + HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal) + ); + } + + #[tokio::test] + async fn host_blocked_allows_scoped_ipv6_literal_when_explicitly_allowlisted() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["fe80::1%lo0".to_string()], + allow_local_binding: false, + ..NetworkPolicy::default() + }); + + assert_eq!( + state.host_blocked("fe80::1%lo0", 80).await.unwrap(), + HostBlockDecision::Allowed + ); + } + + #[tokio::test] + async fn host_blocked_rejects_private_ip_literals_when_local_binding_disabled() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["example.com".to_string()], + allow_local_binding: false, + ..NetworkPolicy::default() + }); + + assert_eq!( + state.host_blocked("10.0.0.1", 80).await.unwrap(), + HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal) + ); + } + + #[tokio::test] + async fn host_blocked_rejects_loopback_when_allowlist_empty() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec![], + allow_local_binding: false, + ..NetworkPolicy::default() + }); + + assert_eq!( + state.host_blocked("127.0.0.1", 80).await.unwrap(), + HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal) + ); + } + + #[test] + fn validate_policy_against_constraints_disallows_widening_allowed_domains() { + let constraints = NetworkProxyConstraints { + allowed_domains: Some(vec!["example.com".to_string()]), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network_proxy: NetworkProxySettings { + enabled: true, + policy: NetworkPolicy { + allowed_domains: vec!["example.com".to_string(), "evil.com".to_string()], + ..NetworkPolicy::default() + }, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_err()); + } + + #[test] + fn validate_policy_against_constraints_disallows_widening_mode() { + let constraints = NetworkProxyConstraints { + mode: Some(NetworkMode::Limited), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network_proxy: NetworkProxySettings { + enabled: true, + mode: NetworkMode::Full, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_err()); + } + + #[test] + fn validate_policy_against_constraints_allows_narrowing_wildcard_allowlist() { + let constraints = NetworkProxyConstraints { + allowed_domains: Some(vec!["*.example.com".to_string()]), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network_proxy: NetworkProxySettings { + enabled: true, + policy: NetworkPolicy { + allowed_domains: vec!["api.example.com".to_string()], + ..NetworkPolicy::default() + }, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_ok()); + } + + #[test] + fn validate_policy_against_constraints_rejects_widening_wildcard_allowlist() { + let constraints = NetworkProxyConstraints { + allowed_domains: Some(vec!["*.example.com".to_string()]), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network_proxy: NetworkProxySettings { + enabled: true, + policy: NetworkPolicy { + allowed_domains: vec!["**.example.com".to_string()], + ..NetworkPolicy::default() + }, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_err()); + } + + #[test] + fn validate_policy_against_constraints_requires_managed_denied_domains_entries() { + let constraints = NetworkProxyConstraints { + denied_domains: Some(vec!["evil.com".to_string()]), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network_proxy: NetworkProxySettings { + enabled: true, + policy: NetworkPolicy { + denied_domains: vec![], + ..NetworkPolicy::default() + }, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_err()); + } + + #[test] + fn validate_policy_against_constraints_disallows_enabling_when_managed_disabled() { + let constraints = NetworkProxyConstraints { + enabled: Some(false), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network_proxy: NetworkProxySettings { + enabled: true, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_err()); + } + + #[test] + fn validate_policy_against_constraints_disallows_allow_local_binding_when_managed_disabled() { + let constraints = NetworkProxyConstraints { + allow_local_binding: Some(false), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network_proxy: NetworkProxySettings { + enabled: true, + policy: NetworkPolicy { + allow_local_binding: true, + ..NetworkPolicy::default() + }, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_err()); + } + + #[test] + fn validate_policy_against_constraints_disallows_non_loopback_admin_without_managed_opt_in() { + let constraints = NetworkProxyConstraints { + dangerously_allow_non_loopback_admin: Some(false), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network_proxy: NetworkProxySettings { + enabled: true, + dangerously_allow_non_loopback_admin: true, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_err()); + } + + #[test] + fn validate_policy_against_constraints_allows_non_loopback_admin_with_managed_opt_in() { + let constraints = NetworkProxyConstraints { + dangerously_allow_non_loopback_admin: Some(true), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network_proxy: NetworkProxySettings { + enabled: true, + dangerously_allow_non_loopback_admin: true, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_ok()); + } + + #[test] + fn compile_globset_is_case_insensitive() { + let patterns = vec!["ExAmPle.CoM".to_string()]; + let set = compile_globset(&patterns).unwrap(); + assert!(set.is_match("example.com")); + assert!(set.is_match("EXAMPLE.COM")); + } + + #[test] + fn compile_globset_excludes_apex_for_subdomain_patterns() { + let patterns = vec!["*.openai.com".to_string()]; + let set = compile_globset(&patterns).unwrap(); + assert!(set.is_match("api.openai.com")); + assert!(!set.is_match("openai.com")); + assert!(!set.is_match("evilopenai.com")); + } + + #[test] + fn compile_globset_includes_apex_for_double_wildcard_patterns() { + let patterns = vec!["**.openai.com".to_string()]; + let set = compile_globset(&patterns).unwrap(); + assert!(set.is_match("openai.com")); + assert!(set.is_match("api.openai.com")); + assert!(!set.is_match("evilopenai.com")); + } + + #[test] + fn compile_globset_matches_all_with_star() { + let patterns = vec!["*".to_string()]; + let set = compile_globset(&patterns).unwrap(); + assert!(set.is_match("openai.com")); + assert!(set.is_match("api.openai.com")); + } + + #[test] + fn compile_globset_dedupes_patterns_without_changing_behavior() { + let patterns = vec!["example.com".to_string(), "example.com".to_string()]; + let set = compile_globset(&patterns).unwrap(); + assert!(set.is_match("example.com")); + assert!(set.is_match("EXAMPLE.COM")); + assert!(!set.is_match("not-example.com")); + } + + #[test] + fn compile_globset_rejects_invalid_patterns() { + let patterns = vec!["[".to_string()]; + assert!(compile_globset(&patterns).is_err()); + } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn unix_socket_allowlist_is_respected_on_macos() { + let socket_path = "/tmp/example.sock".to_string(); + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["example.com".to_string()], + allow_unix_sockets: vec![socket_path.clone()], + ..NetworkPolicy::default() + }); + + assert!(state.is_unix_socket_allowed(&socket_path).await.unwrap()); + assert!( + !state + .is_unix_socket_allowed("/tmp/not-allowed.sock") + .await + .unwrap() + ); + } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn unix_socket_allowlist_resolves_symlinks() { + use std::os::unix::fs::symlink; + use tempfile::tempdir; + + let temp_dir = tempdir().unwrap(); + let dir = temp_dir.path(); + + let real = dir.join("real.sock"); + let link = dir.join("link.sock"); + + // The allowlist mechanism is path-based; for test purposes we don't need an actual unix + // domain socket. Any filesystem entry works for canonicalization. + std::fs::write(&real, b"not a socket").unwrap(); + symlink(&real, &link).unwrap(); + + let real_s = real.to_str().unwrap().to_string(); + let link_s = link.to_str().unwrap().to_string(); + + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["example.com".to_string()], + allow_unix_sockets: vec![real_s], + ..NetworkPolicy::default() + }); + + assert!(state.is_unix_socket_allowed(&link_s).await.unwrap()); + } + + #[cfg(not(target_os = "macos"))] + #[tokio::test] + async fn unix_socket_allowlist_is_rejected_on_non_macos() { + let socket_path = "/tmp/example.sock".to_string(); + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["example.com".to_string()], + allow_unix_sockets: vec![socket_path.clone()], + ..NetworkPolicy::default() + }); + + assert!(!state.is_unix_socket_allowed(&socket_path).await.unwrap()); + } +} diff --git a/codex-rs/network-proxy/src/state.rs b/codex-rs/network-proxy/src/state.rs new file mode 100644 index 0000000000..e1e8a1e4e6 --- /dev/null +++ b/codex-rs/network-proxy/src/state.rs @@ -0,0 +1,419 @@ +use crate::config::NetworkMode; +use crate::config::NetworkProxyConfig; +use crate::policy::DomainPattern; +use crate::policy::compile_globset; +use crate::runtime::ConfigState; +use crate::runtime::LayerMtime; +use anyhow::Context; +use anyhow::Result; +use codex_app_server_protocol::ConfigLayerSource; +use codex_core::config::CONFIG_TOML_FILE; +use codex_core::config::Constrained; +use codex_core::config::ConstraintError; +use codex_core::config::find_codex_home; +use codex_core::config_loader::ConfigLayerStack; +use codex_core::config_loader::ConfigLayerStackOrdering; +use codex_core::config_loader::LoaderOverrides; +use codex_core::config_loader::RequirementSource; +use codex_core::config_loader::load_config_layers_state; +use serde::Deserialize; +use std::collections::HashSet; + +pub use crate::runtime::BlockedRequest; +pub use crate::runtime::NetworkProxyState; +#[cfg(test)] +pub(crate) use crate::runtime::network_proxy_state_for_policy; + +pub(crate) async fn build_config_state() -> Result { + // Load config through `codex-core` so we inherit the same layer ordering and semantics as the + // rest of Codex (system/managed layers, user layers, session flags, etc.). + let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?; + let cli_overrides = Vec::new(); + let overrides = LoaderOverrides::default(); + let config_layer_stack = load_config_layers_state(&codex_home, None, &cli_overrides, overrides) + .await + .context("failed to load Codex config")?; + + let cfg_path = codex_home.join(CONFIG_TOML_FILE); + + // Deserialize from the merged effective config, rather than parsing config.toml ourselves. + // This avoids a second parser/merger implementation (and the drift that comes with it). + let merged_toml = config_layer_stack.effective_config(); + let config: NetworkProxyConfig = merged_toml + .try_into() + .context("failed to deserialize network proxy config")?; + + // Security boundary: user-controlled layers must not be able to widen restrictions set by + // trusted/managed layers (e.g., MDM). Enforce this before building runtime state. + let constraints = enforce_trusted_constraints(&config_layer_stack, &config)?; + + let layer_mtimes = collect_layer_mtimes(&config_layer_stack); + let deny_set = compile_globset(&config.network_proxy.policy.denied_domains)?; + let allow_set = compile_globset(&config.network_proxy.policy.allowed_domains)?; + Ok(ConfigState { + config, + allow_set, + deny_set, + constraints, + layer_mtimes, + cfg_path, + blocked: std::collections::VecDeque::new(), + }) +} + +fn collect_layer_mtimes(stack: &ConfigLayerStack) -> Vec { + stack + .get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, false) + .iter() + .filter_map(|layer| { + let path = match &layer.name { + ConfigLayerSource::System { file } => Some(file.as_path().to_path_buf()), + ConfigLayerSource::User { file } => Some(file.as_path().to_path_buf()), + ConfigLayerSource::Project { dot_codex_folder } => dot_codex_folder + .join(CONFIG_TOML_FILE) + .ok() + .map(|p| p.as_path().to_path_buf()), + ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => { + Some(file.as_path().to_path_buf()) + } + _ => None, + }; + path.map(LayerMtime::new) + }) + .collect() +} + +#[derive(Debug, Default, Deserialize)] +struct PartialConfig { + #[serde(default)] + network_proxy: PartialNetworkProxyConfig, +} + +#[derive(Debug, Default, Deserialize)] +struct PartialNetworkProxyConfig { + enabled: Option, + mode: Option, + allow_upstream_proxy: Option, + dangerously_allow_non_loopback_proxy: Option, + dangerously_allow_non_loopback_admin: Option, + #[serde(default)] + policy: PartialNetworkPolicy, +} + +#[derive(Debug, Default, Deserialize)] +struct PartialNetworkPolicy { + #[serde(default)] + allowed_domains: Option>, + #[serde(default)] + denied_domains: Option>, + #[serde(default)] + allow_unix_sockets: Option>, + #[serde(default)] + allow_local_binding: Option, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub(crate) struct NetworkProxyConstraints { + pub(crate) enabled: Option, + pub(crate) mode: Option, + pub(crate) allow_upstream_proxy: Option, + pub(crate) dangerously_allow_non_loopback_proxy: Option, + pub(crate) dangerously_allow_non_loopback_admin: Option, + pub(crate) allowed_domains: Option>, + pub(crate) denied_domains: Option>, + pub(crate) allow_unix_sockets: Option>, + pub(crate) allow_local_binding: Option, +} + +fn enforce_trusted_constraints( + layers: &codex_core::config_loader::ConfigLayerStack, + config: &NetworkProxyConfig, +) -> Result { + let constraints = network_proxy_constraints_from_trusted_layers(layers)?; + validate_policy_against_constraints(config, &constraints) + .context("network proxy constraints")?; + Ok(constraints) +} + +fn network_proxy_constraints_from_trusted_layers( + layers: &codex_core::config_loader::ConfigLayerStack, +) -> Result { + let mut constraints = NetworkProxyConstraints::default(); + for layer in layers.get_layers( + codex_core::config_loader::ConfigLayerStackOrdering::LowestPrecedenceFirst, + false, + ) { + // Only trusted layers contribute constraints. User-controlled layers can narrow policy but + // must never widen beyond what managed config allows. + if is_user_controlled_layer(&layer.name) { + continue; + } + + let partial: PartialConfig = layer + .config + .clone() + .try_into() + .context("failed to deserialize trusted config layer")?; + + if let Some(enabled) = partial.network_proxy.enabled { + constraints.enabled = Some(enabled); + } + if let Some(mode) = partial.network_proxy.mode { + constraints.mode = Some(mode); + } + if let Some(allow_upstream_proxy) = partial.network_proxy.allow_upstream_proxy { + constraints.allow_upstream_proxy = Some(allow_upstream_proxy); + } + if let Some(dangerously_allow_non_loopback_proxy) = + partial.network_proxy.dangerously_allow_non_loopback_proxy + { + constraints.dangerously_allow_non_loopback_proxy = + Some(dangerously_allow_non_loopback_proxy); + } + if let Some(dangerously_allow_non_loopback_admin) = + partial.network_proxy.dangerously_allow_non_loopback_admin + { + constraints.dangerously_allow_non_loopback_admin = + Some(dangerously_allow_non_loopback_admin); + } + + if let Some(allowed_domains) = partial.network_proxy.policy.allowed_domains { + constraints.allowed_domains = Some(allowed_domains); + } + if let Some(denied_domains) = partial.network_proxy.policy.denied_domains { + constraints.denied_domains = Some(denied_domains); + } + if let Some(allow_unix_sockets) = partial.network_proxy.policy.allow_unix_sockets { + constraints.allow_unix_sockets = Some(allow_unix_sockets); + } + if let Some(allow_local_binding) = partial.network_proxy.policy.allow_local_binding { + constraints.allow_local_binding = Some(allow_local_binding); + } + } + Ok(constraints) +} + +fn is_user_controlled_layer(layer: &ConfigLayerSource) -> bool { + matches!( + layer, + ConfigLayerSource::User { .. } + | ConfigLayerSource::Project { .. } + | ConfigLayerSource::SessionFlags + ) +} + +pub(crate) fn validate_policy_against_constraints( + config: &NetworkProxyConfig, + constraints: &NetworkProxyConstraints, +) -> std::result::Result<(), ConstraintError> { + fn invalid_value( + field_name: &'static str, + candidate: impl Into, + allowed: impl Into, + ) -> ConstraintError { + ConstraintError::InvalidValue { + field_name, + candidate: candidate.into(), + allowed: allowed.into(), + requirement_source: RequirementSource::Unknown, + } + } + + let enabled = config.network_proxy.enabled; + if let Some(max_enabled) = constraints.enabled { + let _ = Constrained::new(enabled, move |candidate| { + if *candidate && !max_enabled { + Err(invalid_value( + "network_proxy.enabled", + "true", + "false (disabled by managed config)", + )) + } else { + Ok(()) + } + })?; + } + + if let Some(max_mode) = constraints.mode { + let _ = Constrained::new(config.network_proxy.mode, move |candidate| { + if network_mode_rank(*candidate) > network_mode_rank(max_mode) { + Err(invalid_value( + "network_proxy.mode", + format!("{candidate:?}"), + format!("{max_mode:?} or more restrictive"), + )) + } else { + Ok(()) + } + })?; + } + + let allow_upstream_proxy = constraints.allow_upstream_proxy; + let _ = Constrained::new( + config.network_proxy.allow_upstream_proxy, + move |candidate| match allow_upstream_proxy { + Some(true) | None => Ok(()), + Some(false) => { + if *candidate { + Err(invalid_value( + "network_proxy.allow_upstream_proxy", + "true", + "false (disabled by managed config)", + )) + } else { + Ok(()) + } + } + }, + )?; + + let allow_non_loopback_admin = constraints.dangerously_allow_non_loopback_admin; + let _ = Constrained::new( + config.network_proxy.dangerously_allow_non_loopback_admin, + move |candidate| match allow_non_loopback_admin { + Some(true) | None => Ok(()), + Some(false) => { + if *candidate { + Err(invalid_value( + "network_proxy.dangerously_allow_non_loopback_admin", + "true", + "false (disabled by managed config)", + )) + } else { + Ok(()) + } + } + }, + )?; + + let allow_non_loopback_proxy = constraints.dangerously_allow_non_loopback_proxy; + let _ = Constrained::new( + config.network_proxy.dangerously_allow_non_loopback_proxy, + move |candidate| match allow_non_loopback_proxy { + Some(true) | None => Ok(()), + Some(false) => { + if *candidate { + Err(invalid_value( + "network_proxy.dangerously_allow_non_loopback_proxy", + "true", + "false (disabled by managed config)", + )) + } else { + Ok(()) + } + } + }, + )?; + + if let Some(allow_local_binding) = constraints.allow_local_binding { + let _ = Constrained::new( + config.network_proxy.policy.allow_local_binding, + move |candidate| { + if *candidate && !allow_local_binding { + Err(invalid_value( + "network_proxy.policy.allow_local_binding", + "true", + "false (disabled by managed config)", + )) + } else { + Ok(()) + } + }, + )?; + } + + if let Some(allowed_domains) = &constraints.allowed_domains { + let managed_patterns: Vec = allowed_domains + .iter() + .map(|entry| DomainPattern::parse_for_constraints(entry)) + .collect(); + let _ = Constrained::new( + config.network_proxy.policy.allowed_domains.clone(), + move |candidate| { + let mut invalid = Vec::new(); + for entry in candidate { + let candidate_pattern = DomainPattern::parse_for_constraints(entry); + if !managed_patterns + .iter() + .any(|managed| managed.allows(&candidate_pattern)) + { + invalid.push(entry.clone()); + } + } + if invalid.is_empty() { + Ok(()) + } else { + Err(invalid_value( + "network_proxy.policy.allowed_domains", + format!("{invalid:?}"), + "subset of managed allowed_domains", + )) + } + }, + )?; + } + + if let Some(denied_domains) = &constraints.denied_domains { + let required_set: HashSet = denied_domains + .iter() + .map(|s| s.to_ascii_lowercase()) + .collect(); + let _ = Constrained::new( + config.network_proxy.policy.denied_domains.clone(), + move |candidate| { + let candidate_set: HashSet = + candidate.iter().map(|s| s.to_ascii_lowercase()).collect(); + let missing: Vec = required_set + .iter() + .filter(|entry| !candidate_set.contains(*entry)) + .cloned() + .collect(); + if missing.is_empty() { + Ok(()) + } else { + Err(invalid_value( + "network_proxy.policy.denied_domains", + "missing managed denied_domains entries", + format!("{missing:?}"), + )) + } + }, + )?; + } + + if let Some(allow_unix_sockets) = &constraints.allow_unix_sockets { + let allowed_set: HashSet = allow_unix_sockets + .iter() + .map(|s| s.to_ascii_lowercase()) + .collect(); + let _ = Constrained::new( + config.network_proxy.policy.allow_unix_sockets.clone(), + move |candidate| { + let mut invalid = Vec::new(); + for entry in candidate { + if !allowed_set.contains(&entry.to_ascii_lowercase()) { + invalid.push(entry.clone()); + } + } + if invalid.is_empty() { + Ok(()) + } else { + Err(invalid_value( + "network_proxy.policy.allow_unix_sockets", + format!("{invalid:?}"), + "subset of managed allow_unix_sockets", + )) + } + }, + )?; + } + + Ok(()) +} + +fn network_mode_rank(mode: NetworkMode) -> u8 { + match mode { + NetworkMode::Limited => 0, + NetworkMode::Full => 1, + } +} diff --git a/codex-rs/network-proxy/src/upstream.rs b/codex-rs/network-proxy/src/upstream.rs new file mode 100644 index 0000000000..9b69955bd4 --- /dev/null +++ b/codex-rs/network-proxy/src/upstream.rs @@ -0,0 +1,188 @@ +use rama_core::Layer; +use rama_core::Service; +use rama_core::error::BoxError; +use rama_core::error::ErrorContext as _; +use rama_core::error::OpaqueError; +use rama_core::extensions::ExtensionsMut; +use rama_core::extensions::ExtensionsRef; +use rama_core::service::BoxService; +use rama_http::Body; +use rama_http::Request; +use rama_http::Response; +use rama_http::layer::version_adapter::RequestVersionAdapter; +use rama_http_backend::client::HttpClientService; +use rama_http_backend::client::HttpConnector; +use rama_http_backend::client::proxy::layer::HttpProxyConnectorLayer; +use rama_net::address::ProxyAddress; +use rama_net::client::EstablishedClientConnection; +use rama_net::http::RequestContext; +use rama_tcp::client::service::TcpConnector; +use rama_tls_boring::client::TlsConnectorDataBuilder; +use rama_tls_boring::client::TlsConnectorLayer; +use tracing::warn; + +#[cfg(target_os = "macos")] +use rama_unix::client::UnixConnector; + +#[derive(Clone, Default)] +struct ProxyConfig { + http: Option, + https: Option, + all: Option, +} + +impl ProxyConfig { + fn from_env() -> Self { + let http = read_proxy_env(&["HTTP_PROXY", "http_proxy"]); + let https = read_proxy_env(&["HTTPS_PROXY", "https_proxy"]); + let all = read_proxy_env(&["ALL_PROXY", "all_proxy"]); + Self { http, https, all } + } + + fn proxy_for_request(&self, req: &Request) -> Option { + let is_secure = RequestContext::try_from(req) + .map(|ctx| ctx.protocol.is_secure()) + .unwrap_or(false); + self.proxy_for_protocol(is_secure) + } + + fn proxy_for_protocol(&self, is_secure: bool) -> Option { + if is_secure { + self.https + .clone() + .or_else(|| self.http.clone()) + .or_else(|| self.all.clone()) + } else { + self.http.clone().or_else(|| self.all.clone()) + } + } +} + +fn read_proxy_env(keys: &[&str]) -> Option { + for key in keys { + let Ok(value) = std::env::var(key) else { + continue; + }; + let value = value.trim(); + if value.is_empty() { + continue; + } + match ProxyAddress::try_from(value) { + Ok(proxy) => { + if proxy + .protocol + .as_ref() + .map(rama_net::Protocol::is_http) + .unwrap_or(true) + { + return Some(proxy); + } + warn!("ignoring {key}: non-http proxy protocol"); + } + Err(err) => { + warn!("ignoring {key}: invalid proxy address ({err})"); + } + } + } + None +} + +pub(crate) fn proxy_for_connect() -> Option { + ProxyConfig::from_env().proxy_for_protocol(true) +} + +#[derive(Clone)] +pub(crate) struct UpstreamClient { + connector: BoxService< + Request, + EstablishedClientConnection, Request>, + BoxError, + >, + proxy_config: ProxyConfig, +} + +impl UpstreamClient { + pub(crate) fn direct() -> Self { + Self::new(ProxyConfig::default()) + } + + pub(crate) fn from_env_proxy() -> Self { + Self::new(ProxyConfig::from_env()) + } + + #[cfg(target_os = "macos")] + pub(crate) fn unix_socket(path: &str) -> Self { + let connector = build_unix_connector(path); + Self { + connector, + proxy_config: ProxyConfig::default(), + } + } + + fn new(proxy_config: ProxyConfig) -> Self { + let connector = build_http_connector(); + Self { + connector, + proxy_config, + } + } +} + +impl Service> for UpstreamClient { + type Output = Response; + type Error = OpaqueError; + + async fn serve(&self, mut req: Request) -> Result { + if let Some(proxy) = self.proxy_config.proxy_for_request(&req) { + req.extensions_mut().insert(proxy); + } + + let uri = req.uri().clone(); + let EstablishedClientConnection { + input: mut req, + conn: http_connection, + } = self + .connector + .serve(req) + .await + .map_err(OpaqueError::from_boxed)?; + + req.extensions_mut() + .extend(http_connection.extensions().clone()); + + http_connection + .serve(req) + .await + .map_err(OpaqueError::from_boxed) + .with_context(|| format!("http request failure for uri: {uri}")) + } +} + +fn build_http_connector() -> BoxService< + Request, + EstablishedClientConnection, Request>, + BoxError, +> { + let transport = TcpConnector::default(); + let proxy = HttpProxyConnectorLayer::optional().into_layer(transport); + let tls_config = TlsConnectorDataBuilder::new_http_auto().into_shared_builder(); + let tls = TlsConnectorLayer::auto() + .with_connector_data(tls_config) + .into_layer(proxy); + let tls = RequestVersionAdapter::new(tls); + let connector = HttpConnector::new(tls); + connector.boxed() +} + +#[cfg(target_os = "macos")] +fn build_unix_connector( + path: &str, +) -> BoxService< + Request, + EstablishedClientConnection, Request>, + BoxError, +> { + let transport = UnixConnector::fixed(path); + let connector = HttpConnector::new(transport); + connector.boxed() +}