Compare commits

...

6 Commits

Author SHA1 Message Date
michael mcgrew
b7ce246480 Merge branch 'dev/mcgrew/network-otel-logs' of https://github.com/openai/codex into dev/mcgrew/network-otel-logs 2026-02-11 15:24:41 -05:00
michael mcgrew
661c78e937 make correct toml_edit version 2026-02-11 15:24:21 -05:00
mcgrew-oai
131b8624b5 Merge branch 'main' into dev/mcgrew/network-otel-logs 2026-02-11 15:20:50 -05:00
michael mcgrew
114b01c01c update toml 2026-02-11 15:17:42 -05:00
michael mcgrew
f801b231cc fix metrics_exporter inconsistencies 2026-02-11 15:01:40 -05:00
michael mcgrew
f8092de827 add OTEL audit logging for policy decisions (embedded + standalone) 2026-02-11 14:24:43 -05:00
14 changed files with 1535 additions and 24 deletions

5
codex-rs/Cargo.lock generated
View File

@@ -2034,8 +2034,11 @@ version = "0.0.0"
dependencies = [
"anyhow",
"async-trait",
"chrono",
"clap",
"codex-otel",
"codex-utils-absolute-path",
"codex-utils-home-dir",
"codex-utils-rustls-provider",
"globset",
"pretty_assertions",
@@ -2053,7 +2056,9 @@ dependencies = [
"thiserror 2.0.18",
"time",
"tokio",
"toml 0.9.12+spec-1.1.0",
"tracing",
"tracing-subscriber",
"url",
]

View File

@@ -47,6 +47,7 @@ use codex_hooks::HookEventAfterAgent;
use codex_hooks::HookPayload;
use codex_hooks::Hooks;
use codex_network_proxy::NetworkProxy;
use codex_network_proxy::NetworkProxyAuditMetadata;
use codex_protocol::ThreadId;
use codex_protocol::approvals::ExecPolicyAmendment;
use codex_protocol::config_types::ModeKind;
@@ -232,6 +233,7 @@ use crate::windows_sandbox::WindowsSandboxLevelExt;
use codex_async_utils::OrCancelExt;
use codex_otel::OtelManager;
use codex_otel::TelemetryAuthMode;
use codex_otel::sanitize_metric_tag_value;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
@@ -1011,16 +1013,33 @@ impl Session {
let auth = auth.as_ref();
let auth_mode = auth.map(CodexAuth::auth_mode).map(TelemetryAuthMode::from);
let account_id = auth.and_then(CodexAuth::get_account_id);
let account_email = auth.and_then(CodexAuth::get_account_email);
let originator = crate::default_client::originator().value;
let terminal_type = terminal::user_agent();
let model = session_configuration.collaboration_mode.model().to_string();
let slug = model.clone();
let network_proxy_audit_metadata = NetworkProxyAuditMetadata {
conversation_id: Some(conversation_id.to_string()),
app_version: Some(env!("CARGO_PKG_VERSION").to_string()),
auth_mode: auth_mode.map(|mode| mode.to_string()),
originator: Some(sanitize_metric_tag_value(originator.as_str())),
account_id: account_id.clone(),
account_email: account_email.clone(),
terminal_type: Some(terminal_type.clone()),
model: Some(model.clone()),
slug: Some(slug.clone()),
};
let otel_manager = OtelManager::new(
conversation_id,
session_configuration.collaboration_mode.model(),
session_configuration.collaboration_mode.model(),
auth.and_then(CodexAuth::get_account_id),
auth.and_then(CodexAuth::get_account_email),
model.as_str(),
slug.as_str(),
account_id,
account_email,
auth_mode,
crate::default_client::originator().value,
originator,
config.otel.log_user_prompt,
terminal::user_agent(),
terminal_type,
session_configuration.session_source.clone(),
);
config.features.emit_metrics(&otel_manager);
@@ -1075,13 +1094,16 @@ impl Session {
};
session_configuration.thread_name = thread_name.clone();
let mut state = SessionState::new(session_configuration.clone());
let network_proxy =
match config.network.as_ref() {
Some(spec) => Some(spec.start_proxy().await.map_err(|err| {
anyhow::anyhow!("failed to start managed network proxy: {err}")
})?),
None => None,
};
let network_proxy = match config.network.as_ref() {
Some(spec) => Some(
spec.start_proxy_with_audit_metadata(network_proxy_audit_metadata)
.await
.map_err(|err| {
anyhow::anyhow!("failed to start managed network proxy: {err}")
})?,
),
None => None,
};
let session_network_proxy = network_proxy.as_ref().map(|started| {
let proxy = started.proxy();
SessionNetworkProxyRuntime {

View File

@@ -4,6 +4,7 @@ use async_trait::async_trait;
use codex_network_proxy::ConfigReloader;
use codex_network_proxy::ConfigState;
use codex_network_proxy::NetworkProxy;
use codex_network_proxy::NetworkProxyAuditMetadata;
use codex_network_proxy::NetworkProxyConfig;
use codex_network_proxy::NetworkProxyConstraints;
use codex_network_proxy::NetworkProxyHandle;
@@ -93,12 +94,21 @@ impl NetworkProxySpec {
}
pub async fn start_proxy(&self) -> std::io::Result<StartedNetworkProxy> {
self.start_proxy_with_audit_metadata(NetworkProxyAuditMetadata::default())
.await
}
pub async fn start_proxy_with_audit_metadata(
&self,
audit_metadata: NetworkProxyAuditMetadata,
) -> std::io::Result<StartedNetworkProxy> {
let state =
build_config_state(self.config.clone(), self.constraints.clone()).map_err(|err| {
std::io::Error::other(format!("failed to build network proxy state: {err}"))
})?;
let reloader = Arc::new(StaticNetworkProxyReloader::new(state.clone()));
let state = NetworkProxyState::with_reloader(state, reloader);
let state =
NetworkProxyState::with_reloader_and_audit_metadata(state, reloader, audit_metadata);
let proxy = NetworkProxy::builder()
.state(Arc::new(state))
.build()

View File

@@ -15,6 +15,9 @@ workspace = true
anyhow = { workspace = true }
async-trait = { workspace = true }
clap = { workspace = true, features = ["derive"] }
chrono = { workspace = true }
codex-utils-home-dir = { workspace = true }
codex-otel = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-rustls-provider = { workspace = true }
globset = { workspace = true }
@@ -24,6 +27,8 @@ thiserror = { workspace = true }
time = { workspace = true }
tokio = { workspace = true, features = ["full"] }
tracing = { workspace = true }
tracing-subscriber = { workspace = true, features = ["env-filter"] }
toml = { workspace = true }
url = { workspace = true }
rama-core = { version = "=0.3.0-alpha.4" }
rama-http = { version = "=0.3.0-alpha.4" }

View File

@@ -12,7 +12,12 @@ It enforces an allow/deny policy and a "limited" mode intended for read-only net
### 1) Configure
`codex-network-proxy` reads from Codex's merged `config.toml` (via `codex-core` config loading).
`codex-network-proxy` has two config-loading modes:
- Standalone binary (`cargo run -p codex-network-proxy --`): reads `network` and `otel`
directly from `$CODEX_HOME/config.toml`.
- Embedded via Codex CLI/core: the proxy is created from Codex-managed network config
(`NetworkProxySpec` / managed constraints), rather than using the standalone binary loader.
Example config:
@@ -55,6 +60,11 @@ allow_unix_sockets = ["/tmp/example.sock"]
cargo run -p codex-network-proxy --
```
Notes:
- If `network.enabled = false` (default), the process exits without binding listeners.
- In standalone mode, `POST /reload` is not supported.
### 3) Point a client at it
For HTTP(S) traffic:
@@ -83,6 +93,91 @@ When a request is blocked, the proxy responds with `403` and includes:
In "limited" mode, only `GET`, `HEAD`, and `OPTIONS` are allowed. HTTPS `CONNECT` and SOCKS5 are
blocked because they would bypass method enforcement.
### 5) OpenTelemetry logs and audit events
`codex-network-proxy` logs use normal `tracing` targets (for example
`codex_network_proxy::http_proxy`).
In standalone mode, `codex-network-proxy` reads the top-level `[otel]` section from
`$CODEX_HOME/config.toml` and initializes OTEL export directly in the binary. If OTEL
initialization fails, the proxy still starts and keeps stderr logging enabled.
In embedded (non-standalone) mode, Codex core initializes OTEL, and the proxy emits audit events
through that shared tracing pipeline.
OTEL resolution follows the same defaults as Codex core (`environment = "dev"`,
`exporter = "none"`, `trace_exporter = exporter`, `metrics_exporter = "statsig"`), and
`log_user_prompt` is accepted for compatibility but ignored by the proxy.
Standalone mode also honors top-level `[analytics].enabled`; when it is `false`, metrics export is
disabled (`metrics_exporter = "none"`), even if a metrics exporter is configured under `[otel]`.
Example:
```toml
[analytics]
enabled = false
[otel]
metrics_exporter = "statsig" # ignored while analytics is disabled
```
To filter proxy logs locally, use:
```bash
RUST_LOG=codex_network_proxy=info
```
The proxy emits structured policy audit events at target `codex_otel.network_proxy` (current
`OTEL_NETWORK_PROXY_TARGET` constant in code):
Domain-policy event (one per domain policy evaluation):
- `event.name = "codex.network_proxy.domain_policy_decision"`
- `event.timestamp = <RFC3339 UTC timestamp with milliseconds>`
- `conversation.id = <thread id>` (optional)
- `app.version = <codex version>` (optional)
- `auth_mode = <auth mode>` (optional)
- `originator = <client originator>` (optional)
- `user.account_id = <account id>` (optional)
- `user.email = <account email>` (optional)
- `terminal.type = <terminal identifier>` (optional)
- `model = <model>` (optional)
- `slug = <model slug>` (optional)
- `network.policy.scope = "domain_rule"`
- `network.policy.decision = "allow" | "deny" | "ask"`
- `network.policy.source = "baseline_policy" | "decider"`
- `network.policy.reason = <policy reason>`
- `network.transport.protocol = "http" | "https_connect" | "socks5_tcp" | "socks5_udp"`
- `server.address = <normalized host>`
- `server.port = <port>`
- `http.request.method = <method or "none">`
- `client.address = <client address or "unknown">`
- `network.policy.override = true|false` (`true` only when decider overrides baseline `not_allowed`)
Supplemental non-domain block event (only when blocked by mode guard or proxy state):
- `event.name = "codex.network_proxy.block_decision"`
- `event.timestamp = <RFC3339 UTC timestamp with milliseconds>`
- `conversation.id = <thread id>` (optional)
- `app.version = <codex version>` (optional)
- `auth_mode = <auth mode>` (optional)
- `originator = <client originator>` (optional)
- `user.account_id = <account id>` (optional)
- `user.email = <account email>` (optional)
- `terminal.type = <terminal identifier>` (optional)
- `model = <model>` (optional)
- `slug = <model slug>` (optional)
- `network.policy.scope = "mode_guard" | "proxy_state"`
- `network.policy.decision = "deny"`
- `network.policy.source = "mode_guard" | "proxy_state"`
- `network.policy.reason = "method_not_allowed" | "proxy_disabled" | "not_allowed" | "unix_socket_unsupported"`
- `network.transport.protocol = "http" | "https_connect" | "socks5_tcp" | "socks5_udp"`
- `server.address = <host>` (`"unix-socket"` sentinel for unix-socket block paths)
- `server.port = <port>` (`0` for unix-socket sentinel events)
- `http.request.method = <method or "none">`
- `client.address = <client address or "unknown">`
- `network.policy.override = false`
These audit events are intentionally domain/policy focused and do not include full URLs.
## Library API
`codex-network-proxy` can be embedded as a library with a thin API:

View File

@@ -6,11 +6,14 @@ use crate::network_policy::NetworkPolicyDecision;
use crate::network_policy::NetworkPolicyRequest;
use crate::network_policy::NetworkPolicyRequestArgs;
use crate::network_policy::NetworkProtocol;
use crate::network_policy::NonDomainDenyAuditEventArgs;
use crate::network_policy::emit_non_domain_deny_audit_event;
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::reasons::REASON_UNIX_SOCKET_UNSUPPORTED;
use crate::responses::PolicyDecisionDetails;
use crate::responses::blocked_header_value;
use crate::responses::blocked_message_with_policy;
@@ -174,6 +177,7 @@ async fn http_connect_accept(
client_addr(&req),
Some("CONNECT".to_string()),
NetworkProtocol::HttpsConnect,
None,
)
.await);
}
@@ -232,6 +236,16 @@ async fn http_connect_accept(
.map_err(|err| internal_error("failed to read network mode", err))?;
if mode == NetworkMode::Limited {
emit_non_domain_deny_audit_event(NonDomainDenyAuditEventArgs {
source: NetworkDecisionSource::ModeGuard,
reason: REASON_METHOD_NOT_ALLOWED,
protocol: NetworkProtocol::HttpsConnect,
host: &host,
port: authority.port,
method: Some("CONNECT"),
client_addr: client.as_deref(),
metadata: app_state.audit_metadata(),
});
let details = PolicyDecisionDetails {
decision: NetworkPolicyDecision::Deny,
reason: REASON_METHOD_NOT_ALLOWED,
@@ -405,10 +419,21 @@ async fn http_plain_proxy(
client_addr(&req),
Some(req.method().as_str().to_string()),
NetworkProtocol::Http,
Some("unix-socket"),
)
.await);
}
if !method_allowed {
emit_non_domain_deny_audit_event(NonDomainDenyAuditEventArgs {
source: NetworkDecisionSource::ModeGuard,
reason: REASON_METHOD_NOT_ALLOWED,
protocol: NetworkProtocol::Http,
host: "unix-socket",
port: 0,
method: Some(req.method().as_str()),
client_addr: client.as_deref(),
metadata: app_state.audit_metadata(),
});
let client = client.as_deref().unwrap_or_default();
let method = req.method();
warn!(
@@ -418,6 +443,16 @@ async fn http_plain_proxy(
}
if !unix_socket_permissions_supported() {
emit_non_domain_deny_audit_event(NonDomainDenyAuditEventArgs {
source: NetworkDecisionSource::ProxyState,
reason: REASON_UNIX_SOCKET_UNSUPPORTED,
protocol: NetworkProtocol::Http,
host: "unix-socket",
port: 0,
method: Some(req.method().as_str()),
client_addr: client.as_deref(),
metadata: app_state.audit_metadata(),
});
warn!("unix socket proxy unsupported on this platform (path={socket_path})");
return Ok(text_response(
StatusCode::NOT_IMPLEMENTED,
@@ -441,6 +476,16 @@ async fn http_plain_proxy(
}
}
Ok(false) => {
emit_non_domain_deny_audit_event(NonDomainDenyAuditEventArgs {
source: NetworkDecisionSource::ProxyState,
reason: REASON_NOT_ALLOWED,
protocol: NetworkProtocol::Http,
host: "unix-socket",
port: 0,
method: Some(req.method().as_str()),
client_addr: client.as_deref(),
metadata: app_state.audit_metadata(),
});
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, None))
@@ -480,6 +525,7 @@ async fn http_plain_proxy(
client_addr(&req),
Some(req.method().as_str().to_string()),
NetworkProtocol::Http,
None,
)
.await);
}
@@ -530,6 +576,16 @@ async fn http_plain_proxy(
}
if !method_allowed {
emit_non_domain_deny_audit_event(NonDomainDenyAuditEventArgs {
source: NetworkDecisionSource::ModeGuard,
reason: REASON_METHOD_NOT_ALLOWED,
protocol: NetworkProtocol::Http,
host: &host,
port,
method: Some(req.method().as_str()),
client_addr: client.as_deref(),
metadata: app_state.audit_metadata(),
});
let details = PolicyDecisionDetails {
decision: NetworkPolicyDecision::Deny,
reason: REASON_METHOD_NOT_ALLOWED,
@@ -657,7 +713,19 @@ async fn proxy_disabled_response(
client: Option<String>,
method: Option<String>,
protocol: NetworkProtocol,
audit_host: Option<&str>,
) -> Response {
emit_non_domain_deny_audit_event(NonDomainDenyAuditEventArgs {
source: NetworkDecisionSource::ProxyState,
reason: REASON_PROXY_DISABLED,
protocol,
host: audit_host.unwrap_or(host.as_str()),
port,
method: method.as_deref(),
client_addr: client.as_deref(),
metadata: app_state.audit_metadata(),
});
let blocked_host = host.clone();
let _ = app_state
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
@@ -718,7 +786,49 @@ mod tests {
use pretty_assertions::assert_eq;
use rama_http::Method;
use rama_http::Request;
use std::io;
use std::sync::Arc;
use std::sync::Mutex;
use tracing_subscriber::fmt::MakeWriter;
#[derive(Clone, Default)]
struct CapturingMakeWriter {
buffer: Arc<Mutex<Vec<u8>>>,
}
impl CapturingMakeWriter {
fn from_buffer(buffer: Arc<Mutex<Vec<u8>>>) -> Self {
Self { buffer }
}
}
impl<'a> MakeWriter<'a> for CapturingMakeWriter {
type Writer = CapturingWriter;
fn make_writer(&'a self) -> Self::Writer {
CapturingWriter {
buffer: self.buffer.clone(),
}
}
}
struct CapturingWriter {
buffer: Arc<Mutex<Vec<u8>>>,
}
impl io::Write for CapturingWriter {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
self.buffer
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[tokio::test]
async fn http_connect_accept_blocks_in_limited_mode() {
@@ -744,4 +854,91 @@ mod tests {
"blocked-by-method-policy"
);
}
#[cfg(not(target_os = "macos"))]
#[tokio::test(flavor = "current_thread")]
async fn http_plain_proxy_emits_unix_socket_unsupported_audit_event() {
let policy = NetworkProxySettings::default();
let state = Arc::new(network_proxy_state_for_policy(policy));
let mut req = Request::builder()
.method(Method::GET)
.uri("http://example.com/")
.header("x-unix-socket", "/tmp/example.sock")
.body(Body::empty())
.unwrap();
req.extensions_mut().insert(state);
let buffer = Arc::new(Mutex::new(Vec::new()));
let subscriber = tracing_subscriber::fmt()
.with_ansi(false)
.with_level(false)
.with_target(true)
.without_time()
.with_writer(CapturingMakeWriter::from_buffer(buffer.clone()))
.finish();
let _guard = tracing::subscriber::set_default(subscriber);
let response = http_plain_proxy(None, req).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
let logs = String::from_utf8(
buffer
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone(),
)
.unwrap();
assert!(logs.contains("event.name=\"codex.network_proxy.block_decision\""));
assert!(logs.contains("network.policy.source=\"proxy_state\""));
assert!(logs.contains("network.policy.reason=\"unix_socket_unsupported\""));
assert!(logs.contains("server.address=\"unix-socket\""));
assert!(logs.contains("server.port=0"));
}
#[cfg(target_os = "macos")]
#[tokio::test(flavor = "current_thread")]
async fn http_plain_proxy_emits_unix_socket_not_allowed_audit_event() {
let policy = NetworkProxySettings {
allow_unix_sockets: vec!["/tmp/allowed.sock".to_string()],
..Default::default()
};
let state = Arc::new(network_proxy_state_for_policy(policy));
let mut req = Request::builder()
.method(Method::GET)
.uri("http://example.com/")
.header("x-unix-socket", "/tmp/not-allowed.sock")
.body(Body::empty())
.unwrap();
req.extensions_mut().insert(state);
let buffer = Arc::new(Mutex::new(Vec::new()));
let subscriber = tracing_subscriber::fmt()
.with_ansi(false)
.with_level(false)
.with_target(true)
.without_time()
.with_writer(CapturingMakeWriter::from_buffer(buffer.clone()))
.finish();
let _guard = tracing::subscriber::set_default(subscriber);
let response = http_plain_proxy(None, req).await.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(
response.headers().get("x-proxy-error").unwrap(),
"blocked-by-allowlist"
);
let logs = String::from_utf8(
buffer
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone(),
)
.unwrap();
assert!(logs.contains("event.name=\"codex.network_proxy.block_decision\""));
assert!(logs.contains("network.policy.source=\"proxy_state\""));
assert!(logs.contains("network.policy.reason=\"not_allowed\""));
assert!(logs.contains("server.address=\"unix-socket\""));
assert!(logs.contains("server.port=0"));
}
}

View File

@@ -34,6 +34,7 @@ pub use proxy::has_proxy_url_env_vars;
pub use proxy::proxy_url_env_value;
pub use runtime::ConfigReloader;
pub use runtime::ConfigState;
pub use runtime::NetworkProxyAuditMetadata;
pub use runtime::NetworkProxyState;
pub use state::NetworkProxyConstraintError;
pub use state::NetworkProxyConstraints;

View File

@@ -0,0 +1,221 @@
mod standalone_otel;
use anyhow::Context;
use anyhow::Result;
use async_trait::async_trait;
use clap::Parser;
use codex_network_proxy::Args;
use codex_network_proxy::ConfigReloader;
use codex_network_proxy::ConfigState;
use codex_network_proxy::NetworkProxy;
use codex_network_proxy::NetworkProxyConfig;
use codex_network_proxy::NetworkProxyConstraints;
use codex_network_proxy::NetworkProxyState;
use codex_network_proxy::build_config_state;
use codex_otel::otel_provider::OtelProvider;
use codex_utils_home_dir::find_codex_home;
use serde::Deserialize;
use std::path::PathBuf;
use std::sync::Arc;
use tracing::info;
use tracing::warn;
use tracing_subscriber::EnvFilter;
use tracing_subscriber::prelude::*;
const CONFIG_TOML_FILE: &str = "config.toml";
struct StandaloneConfig {
codex_home: PathBuf,
config_path: PathBuf,
config_missing: bool,
network: NetworkProxyConfig,
otel: standalone_otel::StandaloneOtelConfigToml,
analytics_enabled: bool,
}
#[derive(Debug, Deserialize, Default)]
struct StandaloneAnalyticsConfigToml {
enabled: Option<bool>,
}
#[derive(Debug, Deserialize, Default)]
struct StandaloneConfigToml {
#[serde(flatten)]
network: NetworkProxyConfig,
#[serde(default)]
otel: standalone_otel::StandaloneOtelConfigToml,
#[serde(default)]
analytics: Option<StandaloneAnalyticsConfigToml>,
}
#[tokio::main]
async fn main() -> Result<()> {
let _ = Args::parse();
let config = load_standalone_config().await?;
let StandaloneConfig {
codex_home,
config_path,
config_missing,
network,
otel: otel_config,
analytics_enabled,
} = config;
let otel_provider = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
standalone_otel::build_provider(
otel_config,
analytics_enabled,
codex_home,
env!("CARGO_PKG_VERSION"),
)
})) {
Ok(Ok(otel)) => otel,
Ok(Err(err)) => {
eprintln!("Could not create otel exporter: {err}");
None
}
Err(_) => {
eprintln!("Could not create otel exporter: panicked during initialization");
None
}
};
init_tracing(otel_provider.as_ref());
if config_missing {
warn!(
"config file not found at {}; using defaults",
config_path.display()
);
}
let state = build_config_state(network, NetworkProxyConstraints::default())?;
let state = NetworkProxyState::with_reloader(state, Arc::new(StandaloneConfigReloader));
let proxy = NetworkProxy::builder()
.state(Arc::new(state))
.managed_by_codex(false)
.build()
.await?;
let handle = proxy.run().await?;
info!(
http = %proxy.http_addr(),
socks = %proxy.socks_addr(),
admin = %proxy.admin_addr(),
"network proxy started"
);
tokio::select! {
result = handle.wait() => result,
_ = tokio::signal::ctrl_c() => Ok(()),
}
}
fn init_tracing(otel: Option<&OtelProvider>) {
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
let stderr_fmt = tracing_subscriber::fmt::layer()
.with_writer(std::io::stderr)
.with_filter(env_filter);
let otel_logger_layer = otel.and_then(|provider| provider.logger_layer());
let otel_tracing_layer = otel.and_then(|provider| provider.tracing_layer());
let _ = tracing_subscriber::registry()
.with(stderr_fmt)
.with(otel_logger_layer)
.with(otel_tracing_layer)
.try_init();
}
async fn load_standalone_config() -> Result<StandaloneConfig> {
let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?;
let config_path = codex_home.join(CONFIG_TOML_FILE);
let raw = match tokio::fs::read_to_string(&config_path).await {
Ok(raw) => Some(raw),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
Err(err) => {
return Err(err).with_context(|| format!("failed to read {}", config_path.display()));
}
};
let (network, otel, analytics_enabled, config_missing) = match raw {
Some(raw) => {
let parsed: StandaloneConfigToml = toml::from_str(&raw)
.with_context(|| format!("failed to parse {}", config_path.display()))?;
let StandaloneConfigToml {
network,
otel,
analytics,
} = parsed;
(network, otel, resolve_analytics_enabled(analytics), false)
}
None => (
NetworkProxyConfig::default(),
standalone_otel::StandaloneOtelConfigToml::default(),
true,
true,
),
};
Ok(StandaloneConfig {
codex_home,
config_path,
config_missing,
network,
otel,
analytics_enabled,
})
}
fn resolve_analytics_enabled(analytics: Option<StandaloneAnalyticsConfigToml>) -> bool {
analytics.and_then(|a| a.enabled).unwrap_or(true)
}
struct StandaloneConfigReloader;
#[async_trait]
impl ConfigReloader for StandaloneConfigReloader {
fn source_label(&self) -> String {
"standalone config state".to_string()
}
async fn maybe_reload(&self) -> Result<Option<ConfigState>> {
Ok(None)
}
async fn reload_now(&self) -> Result<ConfigState> {
Err(anyhow::anyhow!(
"config reload is not supported in standalone mode"
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn standalone_config_analytics_defaults_enabled_when_omitted() {
let parsed: StandaloneConfigToml = toml::from_str(
r#"
[network]
enabled = true
"#,
)
.unwrap();
assert_eq!(resolve_analytics_enabled(parsed.analytics), true);
}
#[test]
fn standalone_config_analytics_respects_disabled_flag() {
let parsed: StandaloneConfigToml = toml::from_str(
r#"
[analytics]
enabled = false
"#,
)
.unwrap();
assert_eq!(resolve_analytics_enabled(parsed.analytics), false);
}
}

View File

@@ -1,12 +1,25 @@
use crate::reasons::REASON_POLICY_DENIED;
use crate::runtime::HostBlockDecision;
use crate::runtime::HostBlockReason;
use crate::state::NetworkProxyAuditMetadata;
use crate::state::NetworkProxyState;
use anyhow::Result;
use async_trait::async_trait;
use chrono::SecondsFormat;
use chrono::Utc;
use std::future::Future;
use std::sync::Arc;
const OTEL_NETWORK_PROXY_TARGET: &str = "codex_otel.network_proxy";
const OTEL_DOMAIN_POLICY_EVENT_NAME: &str = "codex.network_proxy.domain_policy_decision";
const OTEL_BLOCK_POLICY_EVENT_NAME: &str = "codex.network_proxy.block_decision";
const DOMAIN_POLICY_SCOPE: &str = "domain_rule";
const POLICY_DECISION_DENY: &str = "deny";
const DOMAIN_POLICY_DECISION_ALLOW: &str = "allow";
const DOMAIN_POLICY_REASON_ALLOWED: &str = "allowed";
const DOMAIN_POLICY_METHOD_NONE: &str = "none";
const DOMAIN_POLICY_CLIENT_UNKNOWN: &str = "unknown";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NetworkProtocol {
Http,
@@ -114,6 +127,49 @@ pub enum NetworkDecision {
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum BaselinePolicyOutcome {
Allowed,
Blocked(HostBlockReason),
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct DomainPolicyAuditEvent {
decision: &'static str,
source: &'static str,
reason: String,
protocol: &'static str,
domain: String,
port: u16,
method: String,
client_addr: String,
policy_override: bool,
metadata: NetworkProxyAuditMetadata,
}
pub(crate) struct NonDomainDenyAuditEventArgs<'a> {
pub source: NetworkDecisionSource,
pub reason: &'a str,
pub protocol: NetworkProtocol,
pub host: &'a str,
pub port: u16,
pub method: Option<&'a str>,
pub client_addr: Option<&'a str>,
pub metadata: &'a NetworkProxyAuditMetadata,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct NonDomainDenyAuditEvent {
source: &'static str,
reason: String,
protocol: &'static str,
domain: String,
port: u16,
method: String,
client_addr: String,
metadata: NetworkProxyAuditMetadata,
}
impl NetworkDecision {
pub fn deny(reason: impl Into<String>) -> Self {
Self::deny_with_source(reason, NetworkDecisionSource::Decider)
@@ -181,23 +237,34 @@ pub(crate) async fn evaluate_host_policy(
decider: Option<&Arc<dyn NetworkPolicyDecider>>,
request: &NetworkPolicyRequest,
) -> Result<NetworkDecision> {
match state.host_blocked(&request.host, request.port).await? {
HostBlockDecision::Allowed => Ok(NetworkDecision::Allow),
HostBlockDecision::Blocked(HostBlockReason::NotAllowed) => {
let baseline_outcome = match state.host_blocked(&request.host, request.port).await? {
HostBlockDecision::Allowed => BaselinePolicyOutcome::Allowed,
HostBlockDecision::Blocked(reason) => BaselinePolicyOutcome::Blocked(reason),
};
let decision = match baseline_outcome {
BaselinePolicyOutcome::Allowed => NetworkDecision::Allow,
BaselinePolicyOutcome::Blocked(HostBlockReason::NotAllowed) => {
if let Some(decider) = decider {
Ok(map_decider_decision(decider.decide(request.clone()).await))
map_decider_decision(decider.decide(request.clone()).await)
} else {
Ok(NetworkDecision::deny_with_source(
NetworkDecision::deny_with_source(
HostBlockReason::NotAllowed.as_str(),
NetworkDecisionSource::BaselinePolicy,
))
)
}
}
HostBlockDecision::Blocked(reason) => Ok(NetworkDecision::deny_with_source(
BaselinePolicyOutcome::Blocked(reason) => NetworkDecision::deny_with_source(
reason.as_str(),
NetworkDecisionSource::BaselinePolicy,
)),
}
),
};
let audit_event =
domain_policy_audit_event(request, baseline_outcome, &decision, state.audit_metadata());
emit_domain_policy_audit_event(&audit_event);
Ok(decision)
}
fn map_decider_decision(decision: NetworkDecision) -> NetworkDecision {
@@ -213,19 +280,488 @@ fn map_decider_decision(decision: NetworkDecision) -> NetworkDecision {
}
}
fn domain_policy_audit_event(
request: &NetworkPolicyRequest,
baseline_outcome: BaselinePolicyOutcome,
decision: &NetworkDecision,
metadata: &NetworkProxyAuditMetadata,
) -> DomainPolicyAuditEvent {
let method = request
.method
.clone()
.unwrap_or_else(|| DOMAIN_POLICY_METHOD_NONE.to_string());
let client_addr = request
.client_addr
.clone()
.unwrap_or_else(|| DOMAIN_POLICY_CLIENT_UNKNOWN.to_string());
match decision {
NetworkDecision::Allow => {
let (source, reason, policy_override) = match baseline_outcome {
BaselinePolicyOutcome::Allowed => (
NetworkDecisionSource::BaselinePolicy.as_str(),
DOMAIN_POLICY_REASON_ALLOWED.to_string(),
false,
),
BaselinePolicyOutcome::Blocked(HostBlockReason::NotAllowed) => (
NetworkDecisionSource::Decider.as_str(),
HostBlockReason::NotAllowed.as_str().to_string(),
true,
),
BaselinePolicyOutcome::Blocked(reason) => (
NetworkDecisionSource::Decider.as_str(),
reason.as_str().to_string(),
false,
),
};
DomainPolicyAuditEvent {
decision: DOMAIN_POLICY_DECISION_ALLOW,
source,
reason,
protocol: request.protocol.as_policy_protocol(),
domain: request.host.clone(),
port: request.port,
method,
client_addr,
policy_override,
metadata: metadata.clone(),
}
}
NetworkDecision::Deny {
reason,
source,
decision,
} => DomainPolicyAuditEvent {
decision: decision.as_str(),
source: source.as_str(),
reason: reason.clone(),
protocol: request.protocol.as_policy_protocol(),
domain: request.host.clone(),
port: request.port,
method,
client_addr,
policy_override: false,
metadata: metadata.clone(),
},
}
}
fn emit_domain_policy_audit_event(event: &DomainPolicyAuditEvent) {
tracing::event!(
target: OTEL_NETWORK_PROXY_TARGET,
tracing::Level::INFO,
event.name = OTEL_DOMAIN_POLICY_EVENT_NAME,
event.timestamp = %audit_event_timestamp(),
conversation.id = event.metadata.conversation_id.as_deref(),
app.version = event.metadata.app_version.as_deref(),
auth_mode = event.metadata.auth_mode.as_deref(),
originator = event.metadata.originator.as_deref(),
user.account_id = event.metadata.account_id.as_deref(),
user.email = event.metadata.account_email.as_deref(),
terminal.type = event.metadata.terminal_type.as_deref(),
model = event.metadata.model.as_deref(),
slug = event.metadata.slug.as_deref(),
network.policy.scope = DOMAIN_POLICY_SCOPE,
network.policy.decision = event.decision,
network.policy.source = event.source,
network.policy.reason = event.reason.as_str(),
network.transport.protocol = event.protocol,
server.address = event.domain.as_str(),
server.port = event.port,
http.request.method = event.method.as_str(),
client.address = event.client_addr.as_str(),
network.policy.override = event.policy_override,
);
}
pub(crate) fn emit_non_domain_deny_audit_event(args: NonDomainDenyAuditEventArgs<'_>) {
let event = non_domain_deny_audit_event(args);
tracing::event!(
target: OTEL_NETWORK_PROXY_TARGET,
tracing::Level::INFO,
event.name = OTEL_BLOCK_POLICY_EVENT_NAME,
event.timestamp = %audit_event_timestamp(),
conversation.id = event.metadata.conversation_id.as_deref(),
app.version = event.metadata.app_version.as_deref(),
auth_mode = event.metadata.auth_mode.as_deref(),
originator = event.metadata.originator.as_deref(),
user.account_id = event.metadata.account_id.as_deref(),
user.email = event.metadata.account_email.as_deref(),
terminal.type = event.metadata.terminal_type.as_deref(),
model = event.metadata.model.as_deref(),
slug = event.metadata.slug.as_deref(),
network.policy.scope = event.source,
network.policy.decision = POLICY_DECISION_DENY,
network.policy.source = event.source,
network.policy.reason = event.reason.as_str(),
network.transport.protocol = event.protocol,
server.address = event.domain.as_str(),
server.port = event.port,
http.request.method = event.method.as_str(),
client.address = event.client_addr.as_str(),
network.policy.override = false,
);
}
fn non_domain_deny_audit_event(args: NonDomainDenyAuditEventArgs<'_>) -> NonDomainDenyAuditEvent {
debug_assert!(matches!(
args.source,
NetworkDecisionSource::ModeGuard | NetworkDecisionSource::ProxyState
));
NonDomainDenyAuditEvent {
source: args.source.as_str(),
reason: args.reason.to_string(),
protocol: args.protocol.as_policy_protocol(),
domain: args.host.to_string(),
port: args.port,
method: args.method.unwrap_or(DOMAIN_POLICY_METHOD_NONE).to_string(),
client_addr: args
.client_addr
.unwrap_or(DOMAIN_POLICY_CLIENT_UNKNOWN)
.to_string(),
metadata: args.metadata.clone(),
}
}
fn audit_event_timestamp() -> String {
Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::NetworkProxySettings;
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 crate::reasons::REASON_PROXY_DISABLED;
use crate::state::network_proxy_state_for_policy;
use chrono::DateTime;
use pretty_assertions::assert_eq;
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
fn sample_request() -> NetworkPolicyRequest {
NetworkPolicyRequest::new(NetworkPolicyRequestArgs {
protocol: NetworkProtocol::Http,
host: "api.example.com".to_string(),
port: 443,
client_addr: Some("127.0.0.1:9999".to_string()),
method: Some("GET".to_string()),
command: None,
exec_policy_hint: None,
})
}
fn sample_metadata() -> NetworkProxyAuditMetadata {
NetworkProxyAuditMetadata {
conversation_id: Some("019c4a22-679d-7eb2-aa9c-b95114e5ee8d".to_string()),
app_version: Some("0.0.0".to_string()),
auth_mode: Some("Chatgpt".to_string()),
originator: Some("codex_cli_rs".to_string()),
account_id: Some("f7f33107-5fb9-4ee1-8922-3eae76b5b5a0".to_string()),
account_email: Some("test@example.com".to_string()),
terminal_type: Some("iTerm.app/3.6.5".to_string()),
model: Some("gpt-5.3-codex".to_string()),
slug: Some("gpt-5.3-codex".to_string()),
}
}
#[test]
fn domain_policy_audit_event_reports_baseline_allow() {
let request = sample_request();
let event = domain_policy_audit_event(
&request,
BaselinePolicyOutcome::Allowed,
&NetworkDecision::Allow,
&NetworkProxyAuditMetadata::default(),
);
assert_eq!(
event,
DomainPolicyAuditEvent {
decision: "allow",
source: "baseline_policy",
reason: "allowed".to_string(),
protocol: "http",
domain: "api.example.com".to_string(),
port: 443,
method: "GET".to_string(),
client_addr: "127.0.0.1:9999".to_string(),
policy_override: false,
metadata: NetworkProxyAuditMetadata::default(),
}
);
}
#[test]
fn domain_policy_audit_event_reports_baseline_deny_denied() {
let request = sample_request();
let decision =
NetworkDecision::deny_with_source(REASON_DENIED, NetworkDecisionSource::BaselinePolicy);
let event = domain_policy_audit_event(
&request,
BaselinePolicyOutcome::Blocked(HostBlockReason::Denied),
&decision,
&NetworkProxyAuditMetadata::default(),
);
assert_eq!(
event,
DomainPolicyAuditEvent {
decision: "deny",
source: "baseline_policy",
reason: REASON_DENIED.to_string(),
protocol: "http",
domain: "api.example.com".to_string(),
port: 443,
method: "GET".to_string(),
client_addr: "127.0.0.1:9999".to_string(),
policy_override: false,
metadata: NetworkProxyAuditMetadata::default(),
}
);
}
#[test]
fn domain_policy_audit_event_reports_baseline_deny_not_allowed_local() {
let request = sample_request();
let decision = NetworkDecision::deny_with_source(
REASON_NOT_ALLOWED_LOCAL,
NetworkDecisionSource::BaselinePolicy,
);
let event = domain_policy_audit_event(
&request,
BaselinePolicyOutcome::Blocked(HostBlockReason::NotAllowedLocal),
&decision,
&NetworkProxyAuditMetadata::default(),
);
assert_eq!(
event,
DomainPolicyAuditEvent {
decision: "deny",
source: "baseline_policy",
reason: REASON_NOT_ALLOWED_LOCAL.to_string(),
protocol: "http",
domain: "api.example.com".to_string(),
port: 443,
method: "GET".to_string(),
client_addr: "127.0.0.1:9999".to_string(),
policy_override: false,
metadata: NetworkProxyAuditMetadata::default(),
}
);
}
#[test]
fn domain_policy_audit_event_reports_decider_override_allow() {
let request = sample_request();
let event = domain_policy_audit_event(
&request,
BaselinePolicyOutcome::Blocked(HostBlockReason::NotAllowed),
&NetworkDecision::Allow,
&NetworkProxyAuditMetadata::default(),
);
assert_eq!(
event,
DomainPolicyAuditEvent {
decision: "allow",
source: "decider",
reason: REASON_NOT_ALLOWED.to_string(),
protocol: "http",
domain: "api.example.com".to_string(),
port: 443,
method: "GET".to_string(),
client_addr: "127.0.0.1:9999".to_string(),
policy_override: true,
metadata: NetworkProxyAuditMetadata::default(),
}
);
}
#[test]
fn domain_policy_audit_event_reports_decider_ask() {
let request = sample_request();
let decision = NetworkDecision::ask_with_source(
"requires_user_approval",
NetworkDecisionSource::Decider,
);
let event = domain_policy_audit_event(
&request,
BaselinePolicyOutcome::Blocked(HostBlockReason::NotAllowed),
&decision,
&NetworkProxyAuditMetadata::default(),
);
assert_eq!(
event,
DomainPolicyAuditEvent {
decision: "ask",
source: "decider",
reason: "requires_user_approval".to_string(),
protocol: "http",
domain: "api.example.com".to_string(),
port: 443,
method: "GET".to_string(),
client_addr: "127.0.0.1:9999".to_string(),
policy_override: false,
metadata: NetworkProxyAuditMetadata::default(),
}
);
}
#[test]
fn domain_policy_audit_event_includes_metadata() {
let request = sample_request();
let metadata = sample_metadata();
let event = domain_policy_audit_event(
&request,
BaselinePolicyOutcome::Allowed,
&NetworkDecision::Allow,
&metadata,
);
assert_eq!(event.metadata, metadata);
}
#[test]
fn non_domain_deny_audit_event_reports_mode_guard_method_block() {
let event = non_domain_deny_audit_event(NonDomainDenyAuditEventArgs {
source: NetworkDecisionSource::ModeGuard,
reason: REASON_METHOD_NOT_ALLOWED,
protocol: NetworkProtocol::Http,
host: "api.example.com",
port: 443,
method: Some("POST"),
client_addr: Some("127.0.0.1:9999"),
metadata: &NetworkProxyAuditMetadata::default(),
});
assert_eq!(
event,
NonDomainDenyAuditEvent {
source: "mode_guard",
reason: REASON_METHOD_NOT_ALLOWED.to_string(),
protocol: "http",
domain: "api.example.com".to_string(),
port: 443,
method: "POST".to_string(),
client_addr: "127.0.0.1:9999".to_string(),
metadata: NetworkProxyAuditMetadata::default(),
}
);
}
#[test]
fn non_domain_deny_audit_event_reports_proxy_state_proxy_disabled() {
let event = non_domain_deny_audit_event(NonDomainDenyAuditEventArgs {
source: NetworkDecisionSource::ProxyState,
reason: REASON_PROXY_DISABLED,
protocol: NetworkProtocol::Socks5Tcp,
host: "api.example.com",
port: 443,
method: None,
client_addr: None,
metadata: &NetworkProxyAuditMetadata::default(),
});
assert_eq!(
event,
NonDomainDenyAuditEvent {
source: "proxy_state",
reason: REASON_PROXY_DISABLED.to_string(),
protocol: "socks5_tcp",
domain: "api.example.com".to_string(),
port: 443,
method: "none".to_string(),
client_addr: "unknown".to_string(),
metadata: NetworkProxyAuditMetadata::default(),
}
);
}
#[test]
fn non_domain_deny_audit_event_uses_none_and_unknown_fallbacks() {
let event = non_domain_deny_audit_event(NonDomainDenyAuditEventArgs {
source: NetworkDecisionSource::ModeGuard,
reason: REASON_METHOD_NOT_ALLOWED,
protocol: NetworkProtocol::Http,
host: "api.example.com",
port: 80,
method: None,
client_addr: None,
metadata: &NetworkProxyAuditMetadata::default(),
});
assert_eq!(event.method, "none");
assert_eq!(event.client_addr, "unknown");
}
#[test]
fn non_domain_deny_audit_event_supports_unix_socket_sentinel() {
let event = non_domain_deny_audit_event(NonDomainDenyAuditEventArgs {
source: NetworkDecisionSource::ModeGuard,
reason: REASON_METHOD_NOT_ALLOWED,
protocol: NetworkProtocol::Http,
host: "unix-socket",
port: 0,
method: Some("POST"),
client_addr: Some("127.0.0.1:9999"),
metadata: &NetworkProxyAuditMetadata::default(),
});
assert_eq!(event.domain, "unix-socket");
assert_eq!(event.port, 0);
}
#[test]
fn non_domain_deny_audit_event_includes_metadata() {
let metadata = sample_metadata();
let event = non_domain_deny_audit_event(NonDomainDenyAuditEventArgs {
source: NetworkDecisionSource::ModeGuard,
reason: REASON_METHOD_NOT_ALLOWED,
protocol: NetworkProtocol::Http,
host: "api.example.com",
port: 80,
method: Some("GET"),
client_addr: Some("127.0.0.1:9999"),
metadata: &metadata,
});
assert_eq!(event.metadata, metadata);
}
#[test]
fn domain_policy_audit_target_is_exportable_by_otel_filter() {
assert!(OTEL_NETWORK_PROXY_TARGET.starts_with("codex_otel"));
}
#[test]
fn audit_event_timestamp_is_rfc3339_utc_with_millisecond_precision() {
let timestamp = audit_event_timestamp();
let parsed = DateTime::parse_from_rfc3339(&timestamp).unwrap();
assert_eq!(parsed.offset().local_minus_utc(), 0);
assert!(timestamp.ends_with('Z'));
assert_eq!(timestamp.matches('.').count(), 1);
assert_eq!(timestamp.split('.').nth(1).unwrap().len(), 4);
}
#[tokio::test]
async fn evaluate_host_policy_invokes_decider_for_not_allowed() {
let state = network_proxy_state_for_policy(NetworkProxySettings::default());

View File

@@ -4,3 +4,4 @@ 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";
pub(crate) const REASON_UNIX_SOCKET_UNSUPPORTED: &str = "unix_socket_unsupported";

View File

@@ -114,6 +114,19 @@ pub struct ConfigState {
pub blocked: VecDeque<BlockedRequest>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct NetworkProxyAuditMetadata {
pub conversation_id: Option<String>,
pub app_version: Option<String>,
pub auth_mode: Option<String>,
pub originator: Option<String>,
pub account_id: Option<String>,
pub account_email: Option<String>,
pub terminal_type: Option<String>,
pub model: Option<String>,
pub slug: Option<String>,
}
#[async_trait]
pub trait ConfigReloader: Send + Sync {
/// Human-readable description of where config is loaded from, for logs.
@@ -129,6 +142,7 @@ pub trait ConfigReloader: Send + Sync {
pub struct NetworkProxyState {
state: Arc<RwLock<ConfigState>>,
reloader: Arc<dyn ConfigReloader>,
audit_metadata: NetworkProxyAuditMetadata,
}
impl std::fmt::Debug for NetworkProxyState {
@@ -144,18 +158,36 @@ impl Clone for NetworkProxyState {
Self {
state: self.state.clone(),
reloader: self.reloader.clone(),
audit_metadata: self.audit_metadata.clone(),
}
}
}
impl NetworkProxyState {
pub fn with_reloader(state: ConfigState, reloader: Arc<dyn ConfigReloader>) -> Self {
Self::with_reloader_and_audit_metadata(
state,
reloader,
NetworkProxyAuditMetadata::default(),
)
}
pub fn with_reloader_and_audit_metadata(
state: ConfigState,
reloader: Arc<dyn ConfigReloader>,
audit_metadata: NetworkProxyAuditMetadata,
) -> Self {
Self {
state: Arc::new(RwLock::new(state)),
reloader,
audit_metadata,
}
}
pub fn audit_metadata(&self) -> &NetworkProxyAuditMetadata {
&self.audit_metadata
}
pub async fn current_cfg(&self) -> Result<NetworkProxyConfig> {
// 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.

View File

@@ -6,6 +6,8 @@ use crate::network_policy::NetworkPolicyDecision;
use crate::network_policy::NetworkPolicyRequest;
use crate::network_policy::NetworkPolicyRequestArgs;
use crate::network_policy::NetworkProtocol;
use crate::network_policy::NonDomainDenyAuditEventArgs;
use crate::network_policy::emit_non_domain_deny_audit_event;
use crate::network_policy::evaluate_host_policy;
use crate::policy::normalize_host;
use crate::reasons::REASON_METHOD_NOT_ALLOWED;
@@ -152,6 +154,16 @@ async fn handle_socks5_tcp(
match app_state.enabled().await {
Ok(true) => {}
Ok(false) => {
emit_non_domain_deny_audit_event(NonDomainDenyAuditEventArgs {
source: NetworkDecisionSource::ProxyState,
reason: REASON_PROXY_DISABLED,
protocol: NetworkProtocol::Socks5Tcp,
host: &host,
port,
method: None,
client_addr: client.as_deref(),
metadata: app_state.audit_metadata(),
});
let details = PolicyDecisionDetails {
decision: NetworkPolicyDecision::Deny,
reason: REASON_PROXY_DISABLED,
@@ -182,6 +194,16 @@ async fn handle_socks5_tcp(
match app_state.network_mode().await {
Ok(NetworkMode::Limited) => {
emit_non_domain_deny_audit_event(NonDomainDenyAuditEventArgs {
source: NetworkDecisionSource::ModeGuard,
reason: REASON_METHOD_NOT_ALLOWED,
protocol: NetworkProtocol::Socks5Tcp,
host: &host,
port,
method: None,
client_addr: client.as_deref(),
metadata: app_state.audit_metadata(),
});
let details = PolicyDecisionDetails {
decision: NetworkPolicyDecision::Deny,
reason: REASON_METHOD_NOT_ALLOWED,
@@ -289,6 +311,16 @@ async fn inspect_socks5_udp(
match state.enabled().await {
Ok(true) => {}
Ok(false) => {
emit_non_domain_deny_audit_event(NonDomainDenyAuditEventArgs {
source: NetworkDecisionSource::ProxyState,
reason: REASON_PROXY_DISABLED,
protocol: NetworkProtocol::Socks5Udp,
host: &host,
port,
method: None,
client_addr: client.as_deref(),
metadata: state.audit_metadata(),
});
let details = PolicyDecisionDetails {
decision: NetworkPolicyDecision::Deny,
reason: REASON_PROXY_DISABLED,
@@ -319,6 +351,16 @@ async fn inspect_socks5_udp(
match state.network_mode().await {
Ok(NetworkMode::Limited) => {
emit_non_domain_deny_audit_event(NonDomainDenyAuditEventArgs {
source: NetworkDecisionSource::ModeGuard,
reason: REASON_METHOD_NOT_ALLOWED,
protocol: NetworkProtocol::Socks5Udp,
host: &host,
port,
method: None,
client_addr: client.as_deref(),
metadata: state.audit_metadata(),
});
let details = PolicyDecisionDetails {
decision: NetworkPolicyDecision::Deny,
reason: REASON_METHOD_NOT_ALLOWED,

View File

@@ -0,0 +1,343 @@
use codex_otel::config::OtelExporter;
use codex_otel::config::OtelHttpProtocol;
use codex_otel::config::OtelSettings;
use codex_otel::config::OtelTlsConfig;
use codex_otel::otel_provider::OtelProvider;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use std::collections::HashMap;
use std::error::Error;
use std::path::PathBuf;
const DEFAULT_OTEL_ENVIRONMENT: &str = "dev";
const STANDALONE_SERVICE_NAME: &str = "codex_network_proxy";
#[derive(Debug, Clone, Deserialize, Default)]
pub(crate) struct StandaloneOtelConfigToml {
#[serde(rename = "log_user_prompt")]
pub(crate) _log_user_prompt: Option<bool>,
pub(crate) environment: Option<String>,
pub(crate) exporter: Option<StandaloneOtelExporterKind>,
pub(crate) trace_exporter: Option<StandaloneOtelExporterKind>,
pub(crate) metrics_exporter: Option<StandaloneOtelExporterKind>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum StandaloneOtelHttpProtocol {
Binary,
Json,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
pub(crate) struct StandaloneOtelTlsConfigToml {
pub(crate) ca_certificate: Option<AbsolutePathBuf>,
pub(crate) client_certificate: Option<AbsolutePathBuf>,
pub(crate) client_private_key: Option<AbsolutePathBuf>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum StandaloneOtelExporterKind {
None,
Statsig,
OtlpHttp {
endpoint: String,
#[serde(default)]
headers: HashMap<String, String>,
protocol: StandaloneOtelHttpProtocol,
#[serde(default)]
tls: Option<StandaloneOtelTlsConfigToml>,
},
OtlpGrpc {
endpoint: String,
#[serde(default)]
headers: HashMap<String, String>,
#[serde(default)]
tls: Option<StandaloneOtelTlsConfigToml>,
},
}
#[cfg(test)]
#[derive(Debug, Deserialize, Default)]
struct StandaloneConfigToml {
#[serde(default)]
otel: Option<StandaloneOtelConfigToml>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ResolvedStandaloneOtelConfig {
pub(crate) environment: String,
pub(crate) exporter: StandaloneOtelExporterKind,
pub(crate) trace_exporter: StandaloneOtelExporterKind,
pub(crate) metrics_exporter: StandaloneOtelExporterKind,
}
#[cfg(test)]
pub(crate) fn parse_otel_config(raw: &str) -> Result<StandaloneOtelConfigToml, toml::de::Error> {
let config: StandaloneConfigToml = toml::from_str(raw)?;
Ok(config.otel.unwrap_or_default())
}
pub(crate) fn resolve_otel_config(
config: StandaloneOtelConfigToml,
analytics_enabled: bool,
) -> ResolvedStandaloneOtelConfig {
let exporter = config.exporter.unwrap_or(StandaloneOtelExporterKind::None);
let trace_exporter = config.trace_exporter.unwrap_or_else(|| exporter.clone());
let metrics_exporter = if analytics_enabled {
config
.metrics_exporter
.unwrap_or(StandaloneOtelExporterKind::Statsig)
} else {
StandaloneOtelExporterKind::None
};
ResolvedStandaloneOtelConfig {
environment: config
.environment
.unwrap_or_else(|| DEFAULT_OTEL_ENVIRONMENT.to_string()),
exporter,
trace_exporter,
metrics_exporter,
}
}
pub(crate) fn build_provider(
config: StandaloneOtelConfigToml,
analytics_enabled: bool,
codex_home: PathBuf,
service_version: &str,
) -> Result<Option<OtelProvider>, Box<dyn Error>> {
let resolved = resolve_otel_config(config, analytics_enabled);
let settings = OtelSettings {
environment: resolved.environment,
service_name: STANDALONE_SERVICE_NAME.to_string(),
service_version: service_version.to_string(),
codex_home,
exporter: to_otel_exporter(resolved.exporter),
trace_exporter: to_otel_exporter(resolved.trace_exporter),
metrics_exporter: to_otel_exporter(resolved.metrics_exporter),
runtime_metrics: false,
};
OtelProvider::from(&settings)
}
fn to_otel_exporter(exporter: StandaloneOtelExporterKind) -> OtelExporter {
match exporter {
StandaloneOtelExporterKind::None => OtelExporter::None,
StandaloneOtelExporterKind::Statsig => OtelExporter::Statsig,
StandaloneOtelExporterKind::OtlpHttp {
endpoint,
headers,
protocol,
tls,
} => OtelExporter::OtlpHttp {
endpoint,
headers,
protocol: to_otel_http_protocol(protocol),
tls: to_otel_tls_config(tls),
},
StandaloneOtelExporterKind::OtlpGrpc {
endpoint,
headers,
tls,
} => OtelExporter::OtlpGrpc {
endpoint,
headers,
tls: to_otel_tls_config(tls),
},
}
}
fn to_otel_http_protocol(protocol: StandaloneOtelHttpProtocol) -> OtelHttpProtocol {
match protocol {
StandaloneOtelHttpProtocol::Binary => OtelHttpProtocol::Binary,
StandaloneOtelHttpProtocol::Json => OtelHttpProtocol::Json,
}
}
fn to_otel_tls_config(config: Option<StandaloneOtelTlsConfigToml>) -> Option<OtelTlsConfig> {
config.map(|config| OtelTlsConfig {
ca_certificate: config.ca_certificate,
client_certificate: config.client_certificate,
client_private_key: config.client_private_key,
})
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn parse_minimal_config_uses_core_defaults() {
let parsed = parse_otel_config(
r#"
[network]
enabled = true
"#,
)
.unwrap();
let resolved = resolve_otel_config(parsed, true);
assert_eq!(
resolved,
ResolvedStandaloneOtelConfig {
environment: "dev".to_string(),
exporter: StandaloneOtelExporterKind::None,
trace_exporter: StandaloneOtelExporterKind::None,
metrics_exporter: StandaloneOtelExporterKind::Statsig,
}
);
}
#[test]
fn parse_otlp_http_exporter_defaults_trace_to_exporter() {
let parsed = parse_otel_config(
r#"
[otel]
environment = "staging"
exporter = { otlp-http = { endpoint = "https://collector.example/v1/logs", protocol = "json", headers = { "x-api-key" = "abc" } } }
"#,
)
.unwrap();
let resolved = resolve_otel_config(parsed, true);
assert_eq!(
resolved,
ResolvedStandaloneOtelConfig {
environment: "staging".to_string(),
exporter: StandaloneOtelExporterKind::OtlpHttp {
endpoint: "https://collector.example/v1/logs".to_string(),
headers: HashMap::from([("x-api-key".to_string(), "abc".to_string())]),
protocol: StandaloneOtelHttpProtocol::Json,
tls: None,
},
trace_exporter: StandaloneOtelExporterKind::OtlpHttp {
endpoint: "https://collector.example/v1/logs".to_string(),
headers: HashMap::from([("x-api-key".to_string(), "abc".to_string())]),
protocol: StandaloneOtelHttpProtocol::Json,
tls: None,
},
metrics_exporter: StandaloneOtelExporterKind::Statsig,
}
);
}
#[test]
fn parse_trace_exporter_independently_of_log_exporter() {
let parsed = parse_otel_config(
r#"
[otel]
trace_exporter = { otlp-grpc = { endpoint = "https://collector.example:4317" } }
"#,
)
.unwrap();
let resolved = resolve_otel_config(parsed, true);
assert_eq!(
resolved,
ResolvedStandaloneOtelConfig {
environment: "dev".to_string(),
exporter: StandaloneOtelExporterKind::None,
trace_exporter: StandaloneOtelExporterKind::OtlpGrpc {
endpoint: "https://collector.example:4317".to_string(),
headers: HashMap::new(),
tls: None,
},
metrics_exporter: StandaloneOtelExporterKind::Statsig,
}
);
}
#[test]
fn parse_log_user_prompt_field_without_error() {
let parsed = parse_otel_config(
r#"
[otel]
log_user_prompt = true
"#,
)
.unwrap();
let resolved = resolve_otel_config(parsed, true);
assert_eq!(
resolved,
ResolvedStandaloneOtelConfig {
environment: "dev".to_string(),
exporter: StandaloneOtelExporterKind::None,
trace_exporter: StandaloneOtelExporterKind::None,
metrics_exporter: StandaloneOtelExporterKind::Statsig,
}
);
}
#[test]
fn parse_unknown_otel_field_forwards_compatibly() {
let parsed = parse_otel_config(
r#"
[otel]
future_field = "ignored"
"#,
)
.unwrap();
let resolved = resolve_otel_config(parsed, true);
assert_eq!(
resolved,
ResolvedStandaloneOtelConfig {
environment: "dev".to_string(),
exporter: StandaloneOtelExporterKind::None,
trace_exporter: StandaloneOtelExporterKind::None,
metrics_exporter: StandaloneOtelExporterKind::Statsig,
}
);
}
#[test]
fn parse_metrics_exporter_defaults_to_none_when_analytics_disabled() {
let parsed = parse_otel_config(
r#"
[otel]
environment = "staging"
"#,
)
.unwrap();
let resolved = resolve_otel_config(parsed, false);
assert_eq!(
resolved,
ResolvedStandaloneOtelConfig {
environment: "staging".to_string(),
exporter: StandaloneOtelExporterKind::None,
trace_exporter: StandaloneOtelExporterKind::None,
metrics_exporter: StandaloneOtelExporterKind::None,
}
);
}
#[test]
fn parse_metrics_exporter_is_none_when_analytics_disabled_even_if_explicitly_set() {
let parsed = parse_otel_config(
r#"
[otel]
metrics_exporter = { otlp-grpc = { endpoint = "https://collector.example:4317" } }
"#,
)
.unwrap();
let resolved = resolve_otel_config(parsed, false);
assert_eq!(
resolved,
ResolvedStandaloneOtelConfig {
environment: "dev".to_string(),
exporter: StandaloneOtelExporterKind::None,
trace_exporter: StandaloneOtelExporterKind::None,
metrics_exporter: StandaloneOtelExporterKind::None,
}
);
}
}

View File

@@ -8,6 +8,7 @@ use std::collections::HashSet;
pub use crate::runtime::BlockedRequest;
pub use crate::runtime::BlockedRequestArgs;
pub use crate::runtime::NetworkProxyAuditMetadata;
pub use crate::runtime::NetworkProxyState;
#[cfg(test)]
pub(crate) use crate::runtime::network_proxy_state_for_policy;